Skip to main content

Web Accessibility: A Practical Guide That Goes Beyond Checklists

A practical, code-heavy guide to web accessibility covering semantic HTML, ARIA, keyboard navigation, screen readers, testing tools, and React/Next.js specific tips.

Priya Patel
15 min read
Web Accessibility: A Practical Guide That Goes Beyond Checklists

Accessibility Is Not a Feature — It Is a Responsibility

I will be direct: most web developers treat accessibility as a checklist item they rush through before deployment. Slap some alt attributes on images, sprinkle a few aria-label tags, run Lighthouse, see a green score, and move on. I did the same thing for years until a colleague who uses a screen reader showed me how unusable most "accessible" websites actually are.

Watching someone navigate a website I built — clicking through 40 irrelevant links before reaching the main content, hearing "button button button" announced by their screen reader because I used <div onClick> instead of <button>, struggling with a modal that trapped keyboard focus in an invisible loop — that changed how I think about this topic entirely.

This guide is the resource I wish I had back then. Not a checklist of WCAG success criteria (though we will reference those), but a practical walkthrough of how to build websites that genuinely work for everyone. Code examples, common mistakes, testing strategies, and framework-specific advice.


Why This Matters More Than You Think

The number of web accessibility lawsuits has increased steadily worldwide. In the US, ADA (Americans with Disabilities Act) lawsuits targeting websites crossed 4,600 in 2025. India's Rights of Persons with Disabilities Act, 2016 mandates that government websites be accessible, and the pressure is expanding to private enterprises. If your company serves Indian government clients or operates in regulated industries, accessibility compliance is not optional.

The Business Case

Around 15% of the world's population lives with some form of disability. In India, the 2011 census counted 26.8 million people with disabilities — and that number is widely considered an undercount. Beyond permanent disabilities, think about situational impairments: a parent holding a baby and browsing one-handed, someone with a temporary arm injury, a user in bright sunlight struggling with low contrast, an elderly person with declining vision.

Making your site accessible expands your audience. It also improves SEO (screen readers and search engine crawlers parse HTML similarly), boosts usability for everyone, and often leads to cleaner, more maintainable code.

The Moral Case

People deserve to use the web. Full stop.


WCAG 2.2: The Key Requirements

The Web Content Accessibility Guidelines (WCAG) 2.2 organize requirements around four principles, often abbreviated as POUR:

PrincipleMeaningExamples
PerceivableUsers must be able to perceive the contentAlt text, captions, sufficient color contrast
OperableUsers must be able to operate the interfaceKeyboard navigation, enough time, no seizure triggers
UnderstandableContent and operation must be understandableReadable text, predictable navigation, input assistance
RobustContent must work with assistive technologiesValid HTML, ARIA when needed, compatible with screen readers

WCAG defines three conformance levels:

  • Level A — Minimum accessibility. You should always meet this.
  • Level AA — The standard most organizations target. Covers the most impactful requirements.
  • Level AAA — The gold standard. Difficult to achieve for all content but worth pursuing for critical flows.

I recommend targeting Level AA for all new projects. It covers color contrast ratios (4.5:1 for normal text, 3:1 for large text), keyboard accessibility, focus indicators, form labels, and error messaging.


Semantic HTML First: The Foundation

The single most impactful thing you can do for accessibility is use semantic HTML elements correctly. Screen readers understand semantic elements natively. They do not understand <div> soup.

Stop Doing This

<!-- Bad: div soup with no semantic meaning -->
<div class="header">
  <div class="nav">
    <div class="nav-item" onclick="goHome()">Home</div>
    <div class="nav-item" onclick="goAbout()">About</div>
  </div>
</div>
<div class="main">
  <div class="article">
    <div class="title">Understanding Accessibility</div>
    <div class="content">
      <div class="paragraph">Some text here...</div>
    </div>
  </div>
</div>
<div class="footer">Copyright 2026</div>

A screen reader sees this as a flat list of text with no structure. There are no landmarks, no headings, no interactive elements — just text inside boxes.

Do This Instead

<!-- Good: semantic elements that screen readers understand -->
<header>
  <nav aria-label="Main navigation">
    <ul>
      <li><a href="/">Home</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </nav>
</header>
<main>
  <article>
    <h1>Understanding Accessibility</h1>
    <p>Some text here...</p>
  </article>
</main>
<footer>
  <p>Copyright 2026</p>
</footer>

The semantic version gives screen readers landmarks (<header>, <nav>, <main>, <footer>) that users can jump between directly. The heading hierarchy (<h1>) creates a document outline. The <a> tags are natively focusable and activatable with Enter. The <ul> tells the screen reader "this is a list of 2 items."

The Semantic HTML Cheat Sheet

Instead of...Use...Why
<div onclick><button>Natively focusable, keyboard-activatable, announced as "button"
<div class="link"><a href>Natively focusable, activatable, announced as "link"
<div class="heading"><h1> to <h6>Creates document outline, screen readers can jump between headings
<div class="list"><ul> or <ol>Announces "list of N items"
<div class="table"><table> with <th>Screen readers announce row/column headers
<div class="input-wrapper"><label> + <input>Associates label text with input for screen readers
<span class="emphasis"><strong> or <em>Conveys emphasis to screen readers

ARIA: When You Need It and When You Do Not

ARIA (Accessible Rich Internet Applications) attributes add accessibility information to elements when native HTML is insufficient. The first rule of ARIA is famous for a reason: no ARIA is better than bad ARIA.

When ARIA Is Necessary

Custom components that have no native HTML equivalent need ARIA:

<!-- Custom tab component — no native HTML tab element exists -->
<div role="tablist" aria-label="Product information">
  <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1">
    Description
  </button>
  <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2">
    Specifications
  </button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
  <p>Product description goes here...</p>
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
  <p>Technical specifications...</p>
</div>

When ARIA Is Harmful

Adding ARIA to elements that already have native semantics can cause confusion:

<!-- Bad: redundant ARIA on native elements -->
<button role="button" aria-label="Submit form">Submit</button>
<!-- The role is already "button", and aria-label overrides the visible text -->

<!-- Good: let native semantics do their job -->
<button type="submit">Submit</button>

<!-- Bad: hiding visible text from screen readers -->
<a href="/about" aria-label="Click here to learn about our company">About Us</a>
<!-- Screen reader says "Click here to learn about our company" instead of "About Us" -->

<!-- Good: visible text IS the accessible name -->
<a href="/about">About Us</a>

Essential ARIA Attributes

  • aria-label — Provides a text label when visible text is insufficient (e.g., icon-only buttons)
  • aria-labelledby — Points to another element whose text serves as the label
  • aria-describedby — Points to an element that provides additional description
  • aria-expanded — Indicates whether a collapsible section is open or closed
  • aria-hidden="true" — Hides decorative elements from screen readers
  • aria-live — Announces dynamic content changes (toast notifications, loading states)
  • role — Defines what an element represents (only use for custom components)

Keyboard Navigation

Every interactive element on your page must be operable with a keyboard alone. No mouse required. This is critical for users with motor impairments and for power users who prefer keyboard navigation.

Focus Order

The tab order should follow the visual order of the page. By default, focusable elements (links, buttons, inputs) are tabbed in DOM order. Do not use tabindex values greater than 0 — it disrupts the natural flow.

<!-- Bad: positive tabindex creates unpredictable focus order -->
<input tabindex="3" placeholder="Email">
<input tabindex="1" placeholder="Name">
<input tabindex="2" placeholder="Phone">

<!-- Good: let DOM order determine focus order -->
<input placeholder="Name">
<input placeholder="Email">
<input placeholder="Phone">

Use tabindex="0" to make a non-interactive element focusable (rare, but needed for custom components). Use tabindex="-1" to make an element programmatically focusable but not in the tab order (useful for focus management).

Focus Styles

Never remove focus indicators without replacing them. The :focus-visible pseudo-class is your friend — it shows focus styles for keyboard users but hides them for mouse users.

/* Bad: removes ALL focus indicators */
*:focus {
  outline: none;
}

/* Good: custom focus style that only shows for keyboard navigation */
:focus-visible {
  outline: 3px solid #3b82f6;
  outline-offset: 2px;
  border-radius: 2px;
}

/* Hide default focus ring for mouse clicks */
:focus:not(:focus-visible) {
  outline: none;
}

Keyboard Traps and Focus Management

Modals must trap focus inside them — tabbing should cycle through modal elements without reaching the page behind it. When the modal closes, focus should return to the element that triggered it.

function openModal(triggerId) {
  const modal = document.getElementById('modal');
  const trigger = document.getElementById(triggerId);
  const focusableElements = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const firstFocusable = focusableElements[0];
  const lastFocusable = focusableElements[focusableElements.length - 1];

  modal.removeAttribute('hidden');
  firstFocusable.focus();

  // Trap focus inside modal
  modal.addEventListener('keydown', function trapFocus(e) {
    if (e.key === 'Tab') {
      if (e.shiftKey && document.activeElement === firstFocusable) {
        e.preventDefault();
        lastFocusable.focus();
      } else if (!e.shiftKey && document.activeElement === lastFocusable) {
        e.preventDefault();
        firstFocusable.focus();
      }
    }

    if (e.key === 'Escape') {
      closeModal();
    }
  });

  function closeModal() {
    modal.setAttribute('hidden', '');
    modal.removeEventListener('keydown', trapFocus);
    trigger.focus(); // Return focus to trigger
  }
}

In 2026, you can also use the native <dialog> element which handles focus trapping automatically:

<dialog id="myDialog">
  <h2>Confirmation</h2>
  <p>Are you sure you want to proceed?</p>
  <button onclick="document.getElementById('myDialog').close()">Close</button>
</dialog>

<button onclick="document.getElementById('myDialog').showModal()">
  Open Dialog
</button>

The <dialog> element with showModal() traps focus, handles Escape to close, and restores focus when closed. Use it instead of building custom modals whenever possible.


Color Contrast

WCAG AA requires:

  • 4.5:1 contrast ratio for normal text (under 18px or 14px bold)
  • 3:1 contrast ratio for large text (18px+ or 14px+ bold)
  • 3:1 contrast ratio for UI components and graphical objects

Testing Contrast

Use the browser DevTools built-in contrast checker. In Chrome DevTools, inspect an element, look at the color picker, and it shows the contrast ratio with a pass/fail indicator.

/* Bad: fails WCAG AA — ratio 2.4:1 */
.text-light {
  color: #9ca3af; /* gray-400 */
  background: #f9fafb; /* gray-50 */
}

/* Good: passes WCAG AA — ratio 4.6:1 */
.text-readable {
  color: #6b7280; /* gray-500 */
  background: #f9fafb; /* gray-50 */
}

/* Good: passes WCAG AAA — ratio 7.1:1 */
.text-high-contrast {
  color: #374151; /* gray-700 */
  background: #f9fafb; /* gray-50 */
}

Do Not Rely on Color Alone

Color should never be the only way to convey information. A red border on an invalid form field is not enough — add a text error message too.

<!-- Bad: color is the only error indicator -->
<input style="border-color: red;" value="invalid-email">

<!-- Good: error message + icon + color -->
<div class="form-field">
  <label for="email">Email address</label>
  <input id="email" aria-invalid="true" aria-describedby="email-error" value="invalid-email">
  <p id="email-error" class="error" role="alert">
    Please enter a valid email address (e.g., [email protected])
  </p>
</div>

Screen Reader Testing

You should test with an actual screen reader, not just automated tools. Here is how to get started:

Free Screen Readers

  • NVDA (Windows) — Free, open-source. The most popular screen reader for testing. Download from nvaccess.org.
  • VoiceOver (macOS/iOS) — Built into Apple devices. Press Cmd+F5 on Mac to toggle it on.
  • TalkBack (Android) — Built into Android. Enable in Settings > Accessibility.

NVDA Quick Start

  1. Download and install NVDA
  2. Press Insert+Space to toggle between browse mode (reading content) and focus mode (interacting with forms)
  3. Press H to jump between headings
  4. Press D to jump between landmarks
  5. Press K to jump between links
  6. Press Tab to move between focusable elements
  7. Press Insert+F7 to see a list of all links, headings, or landmarks on the page

What to Listen For

When testing with a screen reader, pay attention to:

  • Are all images described meaningfully (or marked as decorative)?
  • Are form fields announced with their labels?
  • Do buttons and links have descriptive text (not just "click here")?
  • Can you navigate to all interactive elements?
  • Are dynamic changes (loading states, toasts, errors) announced?
  • Does the heading hierarchy make sense when listed?

Form Accessibility: Getting It Right

Forms are where accessibility most often breaks down. Every form field needs a properly associated label.

<!-- Method 1: Wrapping label (simplest) -->
<label>
  Email address
  <input type="email" name="email" required>
</label>

<!-- Method 2: for/id association -->
<label for="password">Password</label>
<input type="password" id="password" name="password" required
       aria-describedby="password-hint">
<p id="password-hint" class="hint">Must be at least 8 characters</p>

<!-- Method 3: aria-label for visually hidden labels -->
<input type="search" aria-label="Search articles" placeholder="Search...">

Error Handling

Errors should be announced immediately and associated with the relevant field:

<form novalidate>
  <div class="form-group">
    <label for="email">Email</label>
    <input type="email" id="email" name="email"
           aria-invalid="true"
           aria-describedby="email-error"
           required>
    <p id="email-error" role="alert" class="error">
      Please enter a valid email address
    </p>
  </div>
</form>

The role="alert" on the error message causes screen readers to announce it immediately when it appears — even if the user's focus is elsewhere. The aria-describedby association ensures the error is also read when the user focuses the input field.


Dynamic Content Announcements

When content changes without a page reload — loading spinners, success messages, live search results — screen readers need to be told about it.

<!-- Live region for status updates -->
<div aria-live="polite" aria-atomic="true" id="status-message" class="sr-only">
  <!-- Populated dynamically -->
</div>
// Announce a status change
function announce(message) {
  const status = document.getElementById('status-message');
  status.textContent = ''; // Clear first to ensure re-announcement
  setTimeout(() => {
    status.textContent = message;
  }, 50);
}

// Usage
announce('3 search results found');
announce('Item added to cart');
announce('Form submitted successfully');
  • aria-live="polite" — Waits for the user to finish their current task before announcing
  • aria-live="assertive" — Interrupts the user immediately (use sparingly, for critical errors only)
  • aria-atomic="true" — Announces the entire region content, not just the changed text

Testing Tools

Automated Testing

  • axe DevTools (browser extension) — The most thorough automated accessibility checker. Runs against WCAG guidelines and explains each issue clearly.
  • Lighthouse (built into Chrome) — Includes an accessibility audit. Good for a quick overview but catches fewer issues than axe.
  • eslint-plugin-jsx-a11y — Catches accessibility issues in React JSX during development. Add it to your ESLint config.
  • Pa11y — Command-line tool for CI/CD pipelines. Run automated accessibility checks on every deployment.

Automated Testing Limitations

Automated tools catch about 30-40% of accessibility issues. They are excellent at finding missing alt text, low contrast, missing form labels, and ARIA misuse. They cannot evaluate whether alt text is meaningful, whether focus order is logical, or whether a custom component is truly usable with a screen reader.

Always combine automated testing with manual screen reader testing. There is no substitute.


React and Next.js Specific Tips

Use Semantic Elements in Components

// Bad: div with click handler
function Card({ onClick, title, children }) {
  return (
    <div className="card" onClick={onClick}>
      <div className="card-title">{title}</div>
      {children}
    </div>
  );
}

// Good: semantic elements with proper roles
function Card({ onClick, title, children }) {
  return (
    <article className="card">
      <h3 className="card-title">{title}</h3>
      {children}
      {onClick && (
        <button onClick={onClick} className="card-action">
          Read more about {title}
        </button>
      )}
    </article>
  );
}

Focus Management After Navigation

In SPAs (Single Page Applications), route changes do not trigger a page reload, so screen readers are not notified. Manage focus manually:

// Next.js: announce route changes
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';

function RouteAnnouncer() {
  const pathname = usePathname();
  const announcerRef = useRef(null);

  useEffect(() => {
    if (announcerRef.current) {
      announcerRef.current.textContent = `Navigated to ${document.title}`;
    }
  }, [pathname]);

  return (
    <div
      ref={announcerRef}
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    />
  );
}

Image Components

// Next.js Image with proper alt text
import Image from 'next/image';

// Informative image — describe the content
<Image src="/chart.png" alt="Bar chart showing 40% revenue growth in Q4 2025" width={800} height={400} />

// Decorative image — empty alt
<Image src="/decorative-wave.svg" alt="" aria-hidden="true" width={1200} height={200} />

ESLint Configuration

{
  "extends": [
    "next/core-web-vitals",
    "plugin:jsx-a11y/recommended"
  ],
  "plugins": ["jsx-a11y"]
}

This catches missing alt attributes, invalid ARIA properties, clickable divs without roles, and many other common issues during development — before they reach production.


Common Mistakes I See Constantly

  1. Icon-only buttons without labels. A hamburger menu icon means nothing to a screen reader. Add aria-label="Open menu".
  2. Placeholder text as the only label. Placeholders disappear when users type and are not reliably announced by all screen readers. Always use a <label>.
  3. Auto-playing video or audio. This is disorienting for screen reader users and annoying for everyone. Always require user interaction to start media.
  4. Skip navigation links missing. Add a "Skip to main content" link as the first focusable element on every page.
  5. Custom select dropdowns that are not keyboard-accessible. If you build a custom dropdown, it needs to support Arrow keys, Enter, Escape, and type-ahead search. Or just use <select>.
  6. Infinite scroll without keyboard access. Screen reader users cannot "scroll" — provide an alternative way to load more content.
  7. Time-limited interactions without extension options. If a session times out, warn the user and offer to extend it.
  8. PDFs and images of text. If content is in a PDF or an image, screen readers cannot read it. Provide HTML alternatives.

Accessibility is not a box you check once. It is a practice you build into every component, every feature, every sprint. Start with semantic HTML, test with a keyboard, run axe, and once a month, spend 30 minutes navigating your own site with a screen reader. That last step alone will reveal more issues than any automated tool ever could.

The web was designed to be accessible to everyone. We just need to stop breaking it.

Advertisement

Advertisement

Ad Space

Share

Priya Patel

Senior Tech Writer

Covers AI, machine learning, and emerging technologies. Previously at TechCrunch India.

Comments (0)

Leave a Comment

Related Articles