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

Your site probably excludes millions of users right now
I'm not exaggerating. Around 15% of the world's population lives with some form of disability, and in India alone the 2011 census counted 26.8 million people with disabilities, a number widely considered an undercount. There's a good chance that someone is trying to use your website with a screen reader as you read this, and failing. They aren't doing anything wrong. Your code is.
Most developers are part of the problem without knowing it. Slap alt attributes on images, sprinkle in a few aria-label tags, run Lighthouse, see a green score, and move on. Then watch a screen reader user try the site.
They tab through 40 irrelevant links before reaching the main content, their screen reader announces "button button button" because the buttons are <div onClick> instead of <button>, and then a modal traps their keyboard focus in an invisible loop. A green Lighthouse score didn't catch any of it.
This post isn't a checklist of WCAG success criteria, though I'll reference them. It's a practical walkthrough of building websites that work for everyone, with code examples, common mistakes, testing strategies and some framework-specific advice for React and Next.js developers. If accessibility has been an afterthought for you, that's normal, and it's fixable.
Why you should care more than you do
Legal pressure is growing
The number of web accessibility lawsuits keeps climbing worldwide, and in the US, ADA (Americans with Disabilities Act) lawsuits targeting websites now run into the thousands every year. India's Rights of Persons with Disabilities Act, 2016 requires government websites to be accessible, and the pressure is spreading to private companies. If your company serves Indian government clients or works in a regulated industry, accessibility compliance is a legal requirement.
It's good business
Think beyond permanent disabilities for a moment. There are situational impairments too: a parent holding a baby and browsing one-handed, someone with a temporary arm injury, a user in bright sunlight squinting at low-contrast text, an elderly person whose vision is declining. All of us are temporarily disabled at some point.
An accessible site reaches more people. It helps SEO too, since screen readers and search engine crawlers parse HTML in remarkably similar ways, and it improves usability for everyone and usually leaves you with cleaner, more maintainable code. If you use Tailwind CSS, the framework has good utilities for focus states and screen-reader-only classes that make a lot of this easier.
And then there's the moral argument
People deserve to use the web. I shouldn't need to say more than that. Somehow we still have to.
WCAG 2.2: what you need to know
The Web Content Accessibility Guidelines (WCAG) 2.2 organize requirements around four principles, often shortened to POUR:
| Principle | Meaning | Examples |
|---|---|---|
| Perceivable | Users must be able to perceive the content | Alt text, captions, sufficient color contrast |
| Operable | Users must be able to operate the interface | Keyboard navigation, enough time, no seizure triggers |
| Understandable | Content and operation must be understandable | Readable text, predictable navigation, input assistance |
| Robust | Content must work with assistive technologies | Valid HTML, ARIA when needed, compatible with screen readers |
WCAG defines three conformance levels:
- Level A is the minimum. You should always meet it.
- Level AA is what most organizations target, and it covers the requirements with the most impact.
- Level AAA is the gold standard. It's hard to reach for all content, but worth pursuing for critical flows.
For new projects I'd target Level AA, which covers color contrast ratios (4.5:1 for normal text, 3:1 for large text), keyboard accessibility, focus indicators, form labels and error messaging. Sounds like a lot. Once the patterns sink in, you stop thinking about them.
Semantic HTML first: everything starts here
If you do one thing for accessibility, use semantic HTML elements correctly. Screen readers understand semantic elements natively, but they don't understand <div> soup. Neither does anyone else, 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, only text inside boxes. Imagine handing someone a book where every page is one continuous paragraph, with no chapter titles and no table of contents. Good luck finding anything.
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. <a> tags are natively focusable and activate with Enter, and <ul> tells the screen reader "this is a list of 2 items." You get all of that for free, without 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. The first rule of ARIA is famous for a reason: no ARIA is better than bad ARIA.
A large share of accessibility bugs in production code come from ARIA that was misused, far more than from ARIA that was missing.
When ARIA is necessary
Custom components with 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
Putting ARIA on 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>
ARIA attributes you'll use most
aria-labelgives an element a text label when the visible text isn't enough (icon-only buttons, for example)aria-labelledbypoints to another element whose text serves as the labelaria-describedbypoints to an element that provides an extra descriptionaria-expandedsays whether a collapsible section is open or closedaria-hidden="true"hides decorative elements from screen readersaria-liveannounces live content changes like toast notifications and loading statesroledefines what an element represents (use it only for custom components)
Keyboard navigation: no mouse required
Every interactive element on your page has to work with a keyboard alone. All of them. People with motor impairments depend on this, and power users who prefer the keyboard will thank you too.
Focus order
Tab order should follow the visual order of the page, and by default focusable elements (links, buttons, inputs) are tabbed in DOM order. Don't use tabindex values greater than 0. They create 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. You'll rarely need it, but custom components do. Use tabindex="-1" to make an element focusable from script without putting it in the tab order, which is handy for managing focus in modals and dialogs.
Focus styles
Never remove focus indicators without replacing them. Please. The :focus-visible pseudo-class is your friend here: it shows focus styles to keyboard users and hides them from 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
A modal has to trap focus inside itself, so tabbing cycles through the modal's elements and never reaches the page behind it. When the modal closes, focus should go back to the element that opened it. Getting this wrong is probably 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 most of this for you:
<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>
Opening a <dialog> with showModal() makes the rest of the page inert, so keyboard and screen reader users can't wander behind the modal; it also closes on Escape and returns focus to the trigger when it closes. (Strictly, that isn't a classic focus trap, because Tab can still move out to the browser's own UI, but it's the behaviour you actually want.) Use it instead of building custom modals whenever you can. Why reinvent something the browser already gives you?
Color contrast
WCAG AA requires:
- 4.5:1 contrast ratio for normal text (under 18pt, or 14pt bold)
- 3:1 contrast ratio for large text (18pt+ or 14pt+ bold, roughly 24px and 18.66px)
- 3:1 contrast ratio for UI components and graphical objects
Testing contrast
Browser DevTools have a built-in contrast checker. In Chrome DevTools, inspect an element and open the color picker, and it'll show the contrast ratio with a pass/fail indicator. 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 you convey information. A red border on an invalid form field isn't enough, so add a text error message too, because some users can't perceive color differences at all and others are on a cheap monitor in a bright room. Redundancy is good design here.
<!-- 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
Test with a real screen reader. Automated tools catch maybe 30-40% of accessibility issues, and the rest takes a human using the site the way a real user would.
Free screen readers
- NVDA (Windows) is free and open source, and it's the most popular screen reader for testing. Download it from nvaccess.org.
- VoiceOver (macOS/iOS) is built into Apple devices. Press Cmd+F5 on a Mac to toggle it on.
- TalkBack (Android) is built into Android. Enable it in Settings > Accessibility.
NVDA quick start
- Download and install NVDA
- Press Insert+Space to toggle between browse mode (reading content) and focus mode (interacting with forms)
- Press H to jump between headings
- Press D to jump between landmarks
- Press K to jump between links
- Press Tab to move between focusable elements
- Press Insert+F7 to see a list of all links, headings, or landmarks on the page
What to listen for
While you test, ask yourself:
- Are all images described meaningfully (or marked as decorative)?
- Are form fields announced with their labels?
- Do buttons and links have descriptive text (something better than "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 answer is "no" or "not sure," you've got work to do. Everyone does.
Form accessibility: where things break most
Forms are where accessibility most often falls apart. Every 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 right away and tied to the field they belong to:
<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 makes screen readers announce it as soon as it appears, even if the user's focus is somewhere else. The aria-describedby association means the error is also read when the user focuses the input. Between them you cover the user who's tabbing through the form and the user who just hit submit 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 have to be told, or the user has 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 until the user finishes their current task before announcingaria-live="assertive"interrupts the user immediately, so save it for critical errorsaria-atomic="true"announces the whole region, not only the text that changed
Testing tools you should be using
Automated testing
- axe DevTools (browser extension) is the most thorough automated checker I know. It runs against the WCAG guidelines and explains each issue clearly.
- Lighthouse, built into Chrome, includes an accessibility audit. It's good for a quick overview but catches fewer issues than axe.
- eslint-plugin-jsx-a11y catches accessibility issues in React JSX while you develop. Add it to your ESLint config and forget about it.
- Pa11y is a command-line tool for CI/CD pipelines, so you can run automated checks on every deployment.
What automated tools miss
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, but they can't judge whether the alt text is meaningful, whether the focus order is logical, or whether a custom component is usable with a screen reader. That takes a human.
Always combine automated testing with manual screen reader testing. There's no way around it.
React and Next.js 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>
);
}
Small change. But that <article>, <h3> and <button> do a huge amount of work for screen reader users, and assistive technology can't make sense of the div version at all.
Focus management after navigation
In SPAs (Single Page Applications), route changes don't reload the page, so screen readers aren't told anything changed. You have to manage it yourself:
// 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"]
}
This catches missing alt attributes, invalid ARIA properties, clickable divs without roles and plenty of other common issues while you develop, long before they reach production. It takes two minutes to set up and saves hours of chasing accessibility bugs later.
Mistakes I see constantly (and have made myself)
- Icon-only buttons without labels. A hamburger menu icon means nothing to a screen reader, so add
aria-label="Open menu". - Placeholder text as the only label. Placeholders disappear when users type, and not every screen reader announces them reliably. Always use a
<label>. - Auto-playing video or audio. It disorients screen reader users and annoys everyone else. Make users start media themselves.
- No skip navigation link. Add a "Skip to main content" link as the first focusable element on every page. It takes five minutes.
- Custom select dropdowns that don't work with a keyboard. A custom dropdown needs Arrow keys, Enter, Escape and type-ahead search. Or just use
<select>. A lot of custom dropdowns in the wild fail this test. - Infinite scroll with no keyboard access. Screen reader users can't "scroll," so give them another way to load more content.
- Time limits with no way to extend them. If a session times out, warn the user and offer more time.
- PDFs and images of text. Screen readers can't read content locked in a PDF or an image, so provide HTML alternatives wherever you can.
You don't have to fix everything today
Accessibility isn't a box you tick once. You build it into every component, every feature and every sprint. If your existing codebase has problems (it probably does), you don't need to fix it all in one heroic weekend, an approach that tends to end in burnout and half-finished fixes.
Start with semantic HTML on your next feature, test it with a keyboard, and run axe. Once a month, spend 30 minutes navigating your own site with a screen reader. That last habit will reveal more issues than any automated tool, and you may be surprised or horrified by what you find. Both reactions are normal.
Small steps are fine. Really. A site that's 50% more accessible than it was last month works for thousands more people. Pick one area to improve each sprint, and in six months you'll have a much better product.
If you're building with Next.js, our Next.js complete tutorial covers setting up the eslint-plugin-jsx-a11y configuration and other accessibility practices specific to the framework.
The web was designed to be accessible to everyone, and most of the time we're the ones breaking it. Fix it one commit at a time.
Anurag Sinha
Founder & Editor
Founder and editor of Tech Tips India. Writes tech guides, buying guides and explainers for Indian readers, with rupee pricing and local availability. Product roundups are researched from manufacturer specs and published reviews, not lab testing.
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

API Design: REST and GraphQL Patterns That Scale
API design guide covering REST, GraphQL, tRPC, authentication, rate limiting, error handling, and testing with Node.js examples.

DSA Roadmap for Placements: What Matters in 2026
Practical DSA roadmap for campus placements in India: topic priorities, platform choices, company patterns, and time management.

Redis Caching: Speed Up Your App in Practice
Redis caching strategies, data structures, and cache invalidation with Node.js, Next.js, and Upstash integration guide.