Aug 2026 · Guide · ~10 min read
WCAG 2.2 Accessibility for React Developers — Practical Guide
By Safdar Ali — frontend engineer, Bengaluru
I'm Safdar Ali, a frontend engineer in Bengaluru. Last quarter I audited a client dashboard that looked polished — clean Tailwind, smooth transitions, Lighthouse performance in the 90s — and failed basic keyboard navigation in under two minutes. Tab order jumped randomly, modals trapped nothing, and icon-only buttons had no labels. WCAG 2.2 is not a legal checkbox for enterprise contracts alone. It is how you ship React UI that works for everyone: screen reader users, keyboard-only users, people on slow 4G with zoom enabled, and your future self debugging at 11pm. This guide covers the wcag 2.2 react patterns I run before every merge.
Why WCAG 2.2 matters for React in 2026
WCAG 2.2 added criteria that directly affect React apps: focus not obscured, dragging movements, target size minimums, and consistent help. React's component model makes accessibility both easier and easier to break — you can encapsulate good patterns in a shared Dialog component, but you can also copy-paste a div-with-onClick button across forty files.
The legal landscape in India is catching up. Government portals and fintech products increasingly require accessibility audits before launch. Even when nobody asks, inclusive UI reduces support tickets — unclear error messages and broken focus management generate more "the form is broken" emails than actual backend failures.
React does not ship accessible components by default. A <button> is focusable; a <motion.div onClick> is not, unless you wire it. Your job is to make the accessible path the default path in your design system.
Focus traps — modals that actually work
A focus trap keeps keyboard focus inside a modal until the user dismisses it. Without one, Tab sends focus to elements behind the overlay — confusing for sighted keyboard users and disorienting for screen reader users who hear content from two layers at once.
// components/ui/AccessibleDialog.tsx
"use client";
import { useEffect, useRef } from "react";
type Props = {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
};
export function AccessibleDialog({ open, onClose, title, children }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
triggerRef.current = document.activeElement as HTMLElement;
dialogRef.current?.showModal();
}, [open]);
function handleClose() {
dialogRef.current?.close();
onClose();
triggerRef.current?.focus(); // return focus to trigger — WCAG 2.4.3
}
if (!open) return null;
return (
<dialog
ref={dialogRef}
aria-labelledby="dialog-title"
onCancel={handleClose}
className="rounded-xl p-6 backdrop:bg-black/50"
>
<h2 id="dialog-title">{title}</h2>
{children}
<button type="button" onClick={handleClose}>Close</button>
</dialog>
);
}Native <dialog> handles focus trapping in modern browsers. For older targets, use Radix UI Dialog or React Aria — both implement WCAG-compliant focus management. Do not build focus traps from scratch unless you enjoy edge-case bugs with Shadow DOM and nested modals.
// BEFORE — div modal, focus escapes to page behind
function BrokenModal({ open, onClose }: Props) {
if (!open) return null;
return (
<motion.div className="fixed inset-0 bg-black/50">
<motion.div className="bg-white p-6">
<button onClick={onClose}>Close</button>
</motion.div>
</motion.div>
);
}
// AFTER — native dialog + focus return
function FixedModal({ open, onClose, title, children }: Props) {
return (
<AccessibleDialog open={open} onClose={onClose} title={title}>
{children}
</AccessibleDialog>
);
}On a recent Bengaluru fintech project, replacing custom modals with Radix Dialog fixed twelve keyboard navigation bugs in one afternoon. The visual design did not change. The audit score did.
ARIA labels — when to use them and when to stop
The first rule of ARIA: do not use ARIA if a native HTML element already communicates the role. A <button> does not need role="button". The second rule: icon-only controls always need an accessible name via aria-label or visually hidden text.
// Icon button — accessible name required
function DeleteButton({ onDelete, itemName }: { onDelete: () => void; itemName: string }) {
return (
<button
type="button"
onClick={onDelete}
aria-label={`Delete ${itemName}`}
className="rounded p-2 hover:bg-red-50"
>
<TrashIcon aria-hidden="true" />
</button>
);
}
// Live region for async status — WCAG 4.1.3 status messages
function SaveStatus({ status }: { status: "idle" | "saving" | "saved" | "error" }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{status === "saving" && "Saving changes…"}
{status === "saved" && "Changes saved successfully"}
{status === "error" && "Save failed. Please try again."}
</p>
);
}Decorative icons must have aria-hidden="true" so screen readers skip them. Meaningful images need alt text. Complex charts need a text summary or data table alternative — WCAG 1.1.1 is still the most common failure I see in React dashboards.
Over-ARIA is a real problem. I have seen components with role="navigation" on a <div> that already wraps a <nav>. Use semantic HTML first. Reach for ARIA when semantics cannot express the widget — tabs, comboboxes, tree views.
Keyboard navigation patterns every React app needs
Every interactive element must be reachable and operable with keyboard alone. That means visible focus indicators (WCAG 2.4.7), logical tab order (2.4.3), and no keyboard traps except intentional modal traps.
// Dropdown menu — arrow keys + Escape
"use client";
import { useState, useRef, useEffect } from "react";
export function MenuDropdown({ label, items }: MenuProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setActiveIndex((i) => Math.min(i + 1, items.length - 1));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
}
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}
return (
<motion.div onKeyDown={onKeyDown}>
<button
ref={buttonRef}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{label}
</button>
{open && (
<ul role="menu">
{items.map((item, i) => (
<li key={item.id} role="menuitem" tabIndex={i === activeIndex ? 0 : -1}>
{item.label}
</li>
))}
</ul>
)}
</motion.div>
);
}Skip links belong on every page with navigation. One link at the top of the DOM that jumps to main content saves screen reader users dozens of tab presses. I add them to every Next.js layout:
// app/layout.tsx — skip link pattern
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:p-2">
Skip to main content
</a>
<Header />
<main id="main-content">{children}</main>
</body>
</html>
);
}Test keyboard nav the way users do: unplug your mouse, Tab through the entire flow, and try to complete the primary task — sign up, checkout, submit a form. If you get lost, your users will too.
WCAG 2.2 React checklist — 12 checks before merge
| Check | WCAG criterion | How to verify |
|---|---|---|
| All images have alt text | 1.1.1 Non-text Content | Inspect img / next/image alt props |
| Colour contrast ≥ 4.5:1 | 1.4.3 Contrast (Minimum) | WebAIM contrast checker on text + bg |
| Focus visible on all interactive elements | 2.4.7 Focus Visible | Tab through — no invisible focus rings |
| Logical tab order | 2.4.3 Focus Order | Tab matches visual reading order |
| Modals trap and return focus | 2.4.3 Focus Order | Open modal, Tab stays inside, Escape returns |
| Icon buttons have labels | 4.1.2 Name, Role, Value | aria-label or visible text on every button |
| Form inputs have associated labels | 1.3.1 Info and Relationships | htmlFor + id or aria-labelledby |
| Error messages linked to fields | 3.3.1 Error Identification | aria-describedby on invalid inputs |
| Touch targets ≥ 24×24px | 2.5.8 Target Size (Minimum) | Measure buttons on mobile viewport |
| No focus obscured by sticky headers | 2.4.11 Focus Not Obscured | Tab to inputs near fixed nav |
| Page has lang attribute | 3.1.1 Language of Page | <html lang="en"> in layout |
| Async status announced | 4.1.3 Status Messages | role="status" or aria-live regions |
I paste this table into PR descriptions for any UI-heavy change. Reviewers check boxes; QA runs the same list manually. Automated tools catch maybe 30–40% of issues — the rest need human keyboard testing.
Accessible forms and error handling in React
Forms are where accessibility audits live or die. Labels must be programmatically associated. Errors must identify the field and suggest a fix. Required fields must not rely on colour alone.
// BEFORE — placeholder as label, colour-only error
function BrokenEmailField({ value, onChange, error }: FieldProps) {
return (
<motion.div>
<input placeholder="Email" value={value} onChange={onChange} className={error ? "border-red-500" : ""} />
</motion.div>
);
}
// AFTER — label, describedby, explicit error text
function AccessibleEmailField({ value, onChange, error }: FieldProps) {
const id = "email-field";
const errorId = "email-error";
return (
<motion.div>
<label htmlFor={id}>Email address</label>
<input
id={id}
type="email"
value={value}
onChange={onChange}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
required
/>
{error && (
<p id={errorId} role="alert" className="text-red-700">
{error}
</p>
)}
</motion.div>
);
}React Hook Form and Zod pair well with accessible patterns — pass error messages to aria-describedby on the field component. Do not toast-only errors for required form validation; screen readers may miss them if focus does not move.
Testing accessibility — axe, eslint, and manual passes
My CI pipeline runs eslint-plugin-jsx-a11y on every pull request. That catches missing alt text and invalid ARIA before review. For deeper checks, I add @axe-core/react in development and run Playwright + axe-core on critical user flows before release.
// playwright/a11y.spec.ts — smoke test critical paths
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("checkout page has no critical a11y violations", async ({ page }) => {
await page.goto("/checkout");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
expect(results.violations.filter((v) => v.impact === "critical")).toEqual([]);
});Automated scans miss keyboard trap logic and whether error messages make sense to humans. Schedule a 30-minute manual keyboard pass for every major feature. If you have budget, hire disabled users for usability testing — no tool replaces that feedback.
In Bengaluru product teams I work with, we add accessibility checks to the definition of done: no story closes without keyboard verification on the happy path and one error path. That single process change caught more bugs than any new eslint rule. Start there before buying enterprise audit tools.
Colour contrast and target size — the silent WCAG 2.2 failures
WCAG 1.4.3 requires 4.5:1 contrast for normal text and 3:1 for large text. Tailwind's text-neutral-400 on white often fails — I see it on placeholder-style labels across Indian startup landing pages. Use WebAIM's contrast checker on every text/background pair in your design tokens, not just the hero headline.
WCAG 2.5.8 (Target Size Minimum) requires interactive targets at least 24×24 CSS pixels. Icon buttons withp-1 and a 16px icon fail on mobile. Add padding or increase hit area with invisible pseudo-elements — users on bumpy auto-rickshaw commutes in Bengaluru tap with thumbs, not laser pointers.
// Touch target fix — 44px minimum for primary actions (Apple HIG; exceeds WCAG minimum)
<button
type="button"
className="min-h-11 min-w-11 px-4 py-2 rounded-lg bg-neutral-900 text-white"
aria-label="Add to cart"
>
<CartIcon aria-hidden="true" className="h-5 w-5" />
</button>Dark mode introduces new contrast pairs. Test both themes. A button that passes in light mode may fail when you switch to dark:bg-neutral-800 with dark:text-neutral-500 — common in dashboards I audit.
Heading hierarchy and landmarks — structure screen readers depend on
One <h1> per page. Do not skip levels (h1 → h3). React developers love component abstraction — I have seen CardTitle render an h3 inside a section that already has no h2. Screen reader users navigate by headings; broken hierarchy is like a book with chapter numbers but no chapter titles.
// app/dashboard/page.tsx — landmarks + heading order
export default function DashboardPage() {
return (
<>
<h1 className="sr-only">Dashboard</h1>
<section aria-labelledby="metrics-heading">
<h2 id="metrics-heading">Key metrics</h2>
<MetricsGrid />
</section>
<section aria-labelledby="activity-heading">
<h2 id="activity-heading">Recent activity</h2>
<ActivityFeed />
</section>
</>
);
}Use <main>, <nav>, and <footer> in your Next.js layout. Landmarks let screen reader users jump to content in one keystroke. A div soup layout forces linear tabbing through every nav link on every page load.
My production accessibility setup
I wrap interactive primitives — Dialog, Dropdown, Tabs, Tooltip — in accessible base components using Radix UI or React Aria. Product engineers import those; they do not rebuild focus management per feature. Tailwind includes an sr-only utility for visually hidden text. Focus rings use focus-visible:ring-2 so mouse users do not see ugly outlines on click.
On this portfolio and client marketing sites, accessibility and performance share goals — semantic HTML, proper heading hierarchy, and optimised images with alt text help both Lighthouse accessibility scores and SEO.
Related reading: RSC vs client components — client islands are where most a11y bugs hide. Questions: safdarali.in/contact.
wcag 2.2 react takeaway: semantic HTML first, ARIA when necessary, keyboard-test every interactive flow, and run the checklist table before merge. Accessibility is not a polish pass — it is part of how you write components from day one. Ship one accessible modal this week using native <dialog> or Radix, and you will understand focus management better than any blog post alone.
Automated accessibility in CI catches regressions humans miss in a hurry. eslint-plugin-jsx-a11y flags obvious mistakes in pull requests. axe-core in Playwright against critical paths — checkout, login, contact — runs in under two minutes on GitHub Actions. Manual keyboard testing still matters for focus order in modals; robots do not replace fifteen minutes tabbing through your app before release. I block merge on critical axe violations the same way I block on TypeScript errors for client work in Bengaluru.
Screen reader testing on mobile matters — VoiceOver on iOS and TalkBack on Android behave differently from NVDA on desktop. I test checkout flows on a real phone with screen reader enabled for five minutes before client handoff. WCAG 2.2 added criteria around focus appearance and dragging movements — if your product uses drag-and-drop Kanban, provide keyboard alternatives or a move menu button per card. Accessibility law and procurement requirements in enterprise deals increasingly reference WCAG 2.2 by name; shipping 2.1 patterns only may fail vendor security questionnaires.
Live regions for toast notifications need aria-live="polite" or role="status" — not both fighting for attention. Error summaries at the top of long forms need role="alert" so screen readers announce submission failures. These patterns work in Server and Client Components; the mistake is div onClick toasts without semantics.
Skip links are still underused — a visually hidden anchor to #main-content as the first focusable element lets keyboard users bypass repetitive navigation on every page load. Add it in root layout.tsx once; test with Tab from a fresh page load. WCAG 2.4.1 Bypass Blocks is Level A — free to implement, high impact on sites with heavy marketing headers I build for Bengaluru startups.
Form labels must be associated with inputs — htmlFor on label matching input id, or aria-label when the design hides visible labels. Placeholder is not a label. Error text should reference the field by id in aria-describedby. These wcag 2.2 react patterns prevent the most common audit failures I see on contact forms for Bengaluru service businesses moving from WordPress to Next.js.
Color alone must not convey state — add text or icons for errors, success, and chart segments. WCAG 1.4.1 Use of Color is Level A. I replace red-only error borders with red border plus error message text on every form field. Quick audit win before you ship the next client dashboard from Bengaluru.
Focus visible must meet WCAG 2.4.11 Focus Appearance — a 2px outline with sufficient contrast, not outline-none on buttons because the designer dislikes rings. focus-visible:ring-2 focus-visible:ring-offset-2 in Tailwind satisfies most cases. Test by tabbing through your homepage with the keyboard only; if you lose track of where focus is, users who rely on keyboards lose track too. That fifteen-minute test costs less than one accessibility lawsuit headline.
If this helped you
I publish free tutorials and write-ups like this in my spare time — no paywall on the guides. If it saved you an afternoon of trial and error, you can support the work:
- Buy me a coffee at buymeacoffee.com/safdarali
- Subscribe to my YouTube channel — it's free; 70+ React & Next.js tutorials
Related reading
More guides on safdarali.in — same author, production-focused.
- Guide
How to Build a Frontend Developer Portfolio That Stands Out
Frontend developer portfolio guide for India — sections, React/Next.js examples, SEO, performance, personal branding, FAQ, and checklist to build and rank.
May 2026Read article →
- Guide
React Server Components vs Client Components — When to Use Which
Practical RSC vs client guide for Next.js App Router — when to use each, real code, bundle before/after, and performance impact.
May 2026Read article →