Skip to main content

Web Accessibility: A Practical Developer Guide

Practical web accessibility guide: semantic HTML, ARIA, keyboard nav, screen readers, and React/Next.js tips.

Priya Patel
18 min read
Web Accessibility: A Practical Developer Guide

Your Site Probably Excludes Millions of Users Right Now

That's not an exaggeration. Around 15% of the world's population lives with some form of disability. In India alone, the 2011 census counted 26.8 million people with disabilities — and that number is widely considered an undercount. Right now, as you're reading this, there's a good chance someone's trying to use your website with a screen reader and failing. Not because they're doing something wrong. Because your code is doing something wrong.

I'll be honest: I was part of the problem for years. I'd slap some alt attributes on images, sprinkle a few aria-label tags, run Lighthouse, see a green score, and move on feeling pretty good about myself. Then a colleague who uses a screen reader showed me how unusable most "accessible" websites actually are. Including mine.

Watching someone try to use 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'd 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 entirely. Humbling doesn't even begin to cover it.

What follows isn't a checklist of WCAG success criteria, though we'll reference those. It's a practical walkthrough of how to build websites that genuinely work for everyone. Code examples, common mistakes, testing strategies, and framework-specific advice for React and Next.js developers. If you've been treating accessibility as an afterthought, I'm not here to judge you. I'm here to help you fix it.


Why You Should Care More Than You Do

Number of web accessibility lawsuits has been climbing 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's expanding to private enterprises. If your company serves Indian government clients or operates in regulated industries, accessibility compliance isn't optional. It's a legal requirement.

Smart Business

Beyond permanent disabilities, think about situational impairments for a moment: 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. We're all temporarily disabled at some point.

Making your site accessible expands your audience. It also improves SEO (screen readers and search engine crawlers parse HTML in remarkably similar ways), boosts usability for everyone, and often leads to cleaner, more maintainable code. If you're using Tailwind CSS, the framework provides excellent utilities for focus states and screen-reader-only classes that make implementing accessibility much easier.

And Then There's the Moral Argument

People deserve to use the web. Full stop. Shouldn't need to say more than that, but somehow we still do.


WCAG 2.2: What You Actually Need to Know

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 — What most organizations target. Covers the most impactful requirements.
  • Level AAA — Gold standard. Difficult to achieve for all content but worth pursuing for critical flows.

I'd recommend targeting Level AA for all new projects. Covers color contrast ratios (4.5:1 for normal text, 3:1 for large text), keyboard accessibility, focus indicators, form labels, and error messaging. Probably seems like a lot, but once you've internalized the patterns, it becomes second nature.


Semantic HTML First: Everything Starts Here

Single most impactful thing you can do for accessibility? Use semantic HTML elements correctly. Screen readers understand semantic elements natively. They don't understand <div> soup. Nobody does, really.

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. No landmarks. No headings. No interactive elements. Just text inside boxes. It's like handing someone a book where every page is one continuous paragraph with no chapter titles, no table of contents, nothing. Good luck finding what you need.

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>

Semantic version gives screen readers landmarks (<header>, <nav>, <main>, <footer>) that users can jump between directly. Heading hierarchy (<h1>) creates a document outline. <a> tags are natively focusable and activatable with Enter. <ul> tells the screen reader "this is a list of 2 items." All for free. You didn't have to add a single line of JavaScript.

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 Don't

ARIA (Accessible Rich Internet Applications) attributes add accessibility information to elements when native HTML isn't enough. First rule of ARIA is famous for a reason: no ARIA is better than bad ARIA.

I think most accessibility bugs I've fixed in production code came from misused ARIA, not missing 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 actually 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 isn't enough (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 live content changes (toast notifications, loading states)
  • role — Defines what an element represents (only use for custom components)

Keyboard Navigation: No Mouse Required

Every interactive element on your page must be operable with a keyboard alone. No exceptions. Not "most of them." All of them. People with motor impairments depend on this, and power users who prefer keyboard navigation will thank you too.

Focus Order

Tab order should follow the visual order of the page. By default, focusable elements (links, buttons, inputs) are tabbed in DOM order. Don't use tabindex values greater than 0 — it creates chaos.

<!-- 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 in modals and dialogs).

Focus Styles

Never remove focus indicators without replacing them. Just don't. :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. Getting this wrong is arguably the most common accessibility failure in modern web apps.

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>

<dialog> element with showModal() traps focus, handles Escape to close, and restores focus when closed. Use it instead of building custom modals whenever possible. Seriously. Why reinvent the wheel when the browser gives you a perfectly good one?


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. Takes about three seconds.

/* 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 */
}

Don't Rely on Color Alone

Color should never be the only way to convey information. Red border on an invalid form field? Not enough. Add a text error message too. Some users can't perceive color differences at all. Others might be on a low-quality monitor in a bright room. Redundancy isn't wasteful here — it's a design principle.

<!-- 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., name@example.com)
  </p>
</div>

Screen Reader Testing: Yes, You Have to Do It

You should test with an actual screen reader, not just automated tools. There's no substitute. Automated tools catch maybe 30-40% of accessibility issues. The rest requires a human being using the site the way a real user would.

Free Screen Readers

  • NVDA (Windows) — Free, open-source. 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 reach all interactive elements?
  • Are live updates (loading states, toasts, errors) announced?
  • Does the heading hierarchy make sense when listed?

If any of those answers are "no" or "not sure," you've got work to do. And that's fine. Everyone does.


Form Accessibility: Where Things Break Most

Forms are where accessibility most often falls apart. Every form field needs a properly associated label. No exceptions.

<!-- 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>

role="alert" on the error message causes screen readers to announce it immediately when it appears — even if the user's focus is elsewhere. aria-describedby association ensures the error is also read when the user focuses the input field. Together they cover both cases: the user who's tabbing through the form and the user who submitted and is waiting to hear what went wrong.


Live Content Announcements

When content changes without a page reload — loading spinners, success messages, live search results — screen readers need to be told about it. Otherwise they'll have no idea anything happened.

<!-- 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 You Should Be Using

Automated Testing

  • axe DevTools (browser extension) — 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 and never look back.
  • Pa11y — Command-line tool for CI/CD pipelines. Run automated accessibility checks on every deployment.

Automated Testing Limitations

Here's the thing: automated tools catch about 30-40% of accessibility issues. They're excellent at finding missing alt text, low contrast, missing form labels, and ARIA misuse. They can't evaluate whether alt text is meaningful, whether focus order is logical, or whether a custom component is truly usable with a screen reader. That judgment requires a human.

Always combine automated testing with manual screen reader testing. There's no shortcut around this. None.


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>
  );
}

Seems like a small change. It isn't. That <article> and <h3> and <button> are doing enormous work for screen reader users. The div version? It's invisible to assistive technology.

Focus Management After Navigation

In SPAs (Single Page Applications), route changes don't trigger a page reload, so screen readers aren't 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.webp" alt="Bar chart showing 40% revenue growth in Q4 2025" width={800} height={400} />

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

ESLint Configuration

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

Catches missing alt attributes, invalid ARIA properties, clickable divs without roles, and many other common issues during development — before they reach production. Two minutes to set up, saves hours of debugging accessibility bugs later.


Mistakes I See Constantly (and Have Made Myself)

  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 aren't reliably announced by all screen readers. Always use a <label>.
  3. Auto-playing video or audio. 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. Takes five minutes to implement.
  5. Custom select dropdowns that aren't keyboard-accessible. If you build a custom dropdown, it needs to support Arrow keys, Enter, Escape, and type-ahead search. Or just use <select>. I suspect most custom dropdowns in the wild fail this test.
  6. Infinite scroll without keyboard access. Screen reader users can't "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 can't read it. Provide HTML alternatives whenever possible.

You Don't Have to Fix Everything Today

Accessibility isn't a box you check once. It's a practice you build into every component, every feature, every sprint. And if your existing codebase has problems — it probably does — you don't need to fix everything in one heroic weekend. That approach leads to burnout and half-finished improvements.

Start with semantic HTML on your next feature. Test it with a keyboard. Run axe. 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. You might be surprised — or horrified — at what you find. Both reactions are normal and healthy.

Incremental progress is fine. Better than fine, actually. A site that's 50% more accessible than it was last month is a site that works for thousands more people. Keep going. Each sprint, pick one area to improve. Over six months, you'll have a dramatically better product.

If you're building with Next.js, our Next.js 14 complete tutorial covers how to set up the eslint-plugin-jsx-a11y configuration and other accessibility best practices specific to the framework.

Web was designed to be accessible to everyone. We just need to stop breaking it. One commit at a time.

Share

Priya Patel

Senior Tech Writer

AI and machine learning specialist with 6 years covering emerging technologies. Previously a senior tech correspondent at TechCrunch India, she now writes in-depth analyses of AI tools, LLM developments, and their real-world applications for Indian businesses.

Stay Ahead in Tech

Get the latest tech news, tutorials, and reviews delivered straight to your inbox every week.

No spam ever. Unsubscribe anytime.

Comments (0)

Leave a Comment

All comments are moderated before appearing. Please be respectful and follow our community guidelines.

Related Articles