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.
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 Legal Case
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:
| 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 — 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 labelaria-describedby— Points to an element that provides additional descriptionaria-expanded— Indicates whether a collapsible section is open or closedaria-hidden="true"— Hides decorative elements from screen readersaria-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
- 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
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 announcingaria-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
- Icon-only buttons without labels. A hamburger menu icon means nothing to a screen reader. Add
aria-label="Open menu". - Placeholder text as the only label. Placeholders disappear when users type and are not reliably announced by all screen readers. Always use a
<label>. - Auto-playing video or audio. This is disorienting for screen reader users and annoying for everyone. Always require user interaction to start media.
- Skip navigation links missing. Add a "Skip to main content" link as the first focusable element on every page.
- 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>. - Infinite scroll without keyboard access. Screen reader users cannot "scroll" — provide an alternative way to load more content.
- Time-limited interactions without extension options. If a session times out, warn the user and offer to extend it.
- 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
Priya Patel
Senior Tech Writer
Covers AI, machine learning, and emerging technologies. Previously at TechCrunch India.
Comments (0)
Leave a Comment
Related Articles
API Design Best Practices: REST, GraphQL, and the Patterns That Scale
A practical guide to designing APIs that last, covering REST conventions, GraphQL fundamentals, tRPC, authentication patterns, rate limiting, error handling, and testing tools with Node.js examples.
DSA Roadmap for Campus Placements: What Actually Matters in 2026
A realistic DSA preparation roadmap for campus placements in India, covering topic priorities, platform choices, language selection, company-wise patterns, and time management strategies.
Redis Caching in Practice: Speed Up Your App Without the Headaches
A practical walkthrough of Redis caching strategies, data structures, cache invalidation, and integration with Node.js, Next.js, and serverless platforms like Upstash.