Web Accessibility (WCAG 2.2): Practical Implementation for React and Next.js
Implement WCAG 2.2 accessibility in React and Next.js — ARIA roles, keyboard navigation, focus management, screen reader testing, and automated auditing with ax
Web Accessibility (WCAG 2.2): Practical Implementation for React and Next.js
Accessibility is rarely the top priority until it's legally required. In 2026, ADA Title III lawsuits targeting inaccessible websites are common — over 4,600 were filed in the US in 2024 alone, most targeting e-commerce and SaaS products. Beyond legal risk, accessible applications have higher conversion rates (the estimated disabled user population with purchasing power exceeds $490 billion annually) and better SEO (semantic HTML and ARIA labels improve crawlability).
This guide covers WCAG 2.2 implementation for React applications — the specific patterns, code, and testing approach that gets you to AA compliance.
WCAG 2.2 Levels and What's Required
WCAG has three conformance levels: A (minimum), AA (standard requirement for most legal compliance), and AAA (aspirational). Most businesses target AA.
New in WCAG 2.2 (beyond 2.1):
| Criterion | Level | What Changed |
|---|---|---|
| 2.4.11 Focus Not Obscured (Minimum) | AA | Focused element must be at least partially visible |
| 2.4.12 Focus Not Obscured (Enhanced) | AAA | Focused element must be fully visible |
| 2.4.13 Focus Appearance | AAA | Focus indicator must be 2px solid with minimum contrast |
| 2.5.7 Dragging Movements | AA | All drag actions need a pointer alternative |
| 2.5.8 Target Size (Minimum) | AA | Interactive targets ≥ 24×24 CSS pixels |
| 3.2.6 Consistent Help | A | Help mechanisms in consistent location |
| 3.3.7 Redundant Entry | A | Don't ask for same info twice in same session |
| 3.3.8 Accessible Authentication | AA | No cognitive tests (CAPTCHA) without alternative |
Semantic HTML: The Foundation
Most accessibility issues start with non-semantic HTML. Before adding ARIA, use the right HTML elements:
// ❌ Non-semantic — screen readers can't interpret structure
<div onClick={handleSubmit}>Submit</div>
<div className="header">...</div>
<div className="nav">...</div>
<div className="article-content">...</div>
// ✅ Semantic — structure is machine-readable
<button type="submit" onClick={handleSubmit}>Submit</button>
<header>...</header>
<nav aria-label="Main navigation">...</nav>
<article>...</article>
// ✅ Landmark regions — screen reader users navigate by landmarks
<header role="banner">
<nav aria-label="Primary navigation">...</nav>
</header>
<main id="main-content"> {/* Skip link target */}
<article aria-labelledby="article-heading">
<h1 id="article-heading">Article Title</h1>
</article>
</main>
<aside aria-label="Related content">...</aside>
<footer role="contentinfo">...</footer>
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Skip Navigation Links
Screen reader and keyboard users must be able to skip repetitive navigation:
// components/SkipNav.tsx
export function SkipNav() {
return (
<a
href="#main-content"
className={`
absolute left-0 top-0 z-50 -translate-y-full
bg-blue-600 px-4 py-2 text-white
focus:translate-y-0 transition-transform
focus:outline-none focus:ring-2 focus:ring-white
`}
>
Skip to main content
</a>
);
}
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SkipNav />
<header>...</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>
</html>
);
}
The tabIndex={-1} on <main> allows the skip link to focus it programmatically without putting it in the natural tab order.
Focus Management
Single-page applications break browser focus management — when content changes, focus stays where it was (often on a now-gone element).
// hooks/useFocusOnMount.ts
import { useEffect, useRef } from 'react';
export function useFocusOnMount<T extends HTMLElement>() {
const ref = useRef<T>(null);
useEffect(() => {
ref.current?.focus();
}, []);
return ref;
}
// When navigating to a new page/view, focus the heading
export default function ProductPage({ product }: { product: Product }) {
const headingRef = useFocusOnMount<HTMLHeadingElement>();
return (
<article>
<h1 ref={headingRef} tabIndex={-1} className="focus:outline-none">
{product.name}
</h1>
...
</article>
);
}
// Modal focus trap
import { useEffect } from 'react';
export function useFocusTrap(containerRef: React.RefObject<HTMLElement>, isActive: boolean) {
useEffect(() => {
if (!isActive || !containerRef.current) return;
const focusableSelectors = [
'button:not([disabled])',
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
const container = containerRef.current;
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelectors);
const firstEl = focusableElements[0];
const lastEl = focusableElements[focusableElements.length - 1];
// Focus first element when modal opens
firstEl?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
}
}
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [isActive, containerRef]);
}
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
ARIA Patterns for Common Components
Accessible modal dialog:
// components/Modal.tsx
import { useEffect, useRef } from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useFocusTrap(containerRef, isOpen);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
} else {
// Restore focus when modal closes
previousFocusRef.current?.focus();
}
}, [isOpen]);
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden'; // Prevent background scroll
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
<div className="flex justify-between items-center mb-4">
<h2 id="modal-title" className="text-xl font-semibold">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="p-2 hover:bg-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<span aria-hidden="true">✕</span>
</button>
</div>
{children}
</div>
</div>
</>
);
}
Accessible accordion:
// components/Accordion.tsx
import { useState } from 'react';
interface AccordionItem {
id: string;
title: string;
content: React.ReactNode;
}
export function Accordion({ items }: { items: AccordionItem[] }) {
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setOpenItems(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div>
{items.map((item) => {
const isOpen = openItems.has(item.id);
const contentId = `accordion-content-${item.id}`;
const headerId = `accordion-header-${item.id}`;
return (
<div key={item.id} className="border-b">
<h3>
<button
id={headerId}
aria-expanded={isOpen}
aria-controls={contentId}
onClick={() => toggle(item.id)}
className="w-full text-left px-4 py-3 flex justify-between items-center focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
>
{item.title}
<span aria-hidden="true">{isOpen ? '▲' : '▼'}</span>
</button>
</h3>
<div
id={contentId}
role="region"
aria-labelledby={headerId}
hidden={!isOpen}
className="px-4 py-3"
>
{item.content}
</div>
</div>
);
})}
</div>
);
}
Color Contrast
WCAG AA requires:
- Normal text (< 18pt): contrast ratio ≥ 4.5:1
- Large text (≥ 18pt or 14pt bold): contrast ratio ≥ 3:1
- UI components and graphics: ≥ 3:1
// Check in Tailwind — use a tool like https://webaim.org/resources/contrastchecker/
// ❌ Common failure: gray text on white
<p className="text-gray-400">This fails — #9CA3AF on white = 2.86:1</p>
// ✅ Pass for normal text
<p className="text-gray-600">This passes — #4B5563 on white = 7.0:1</p>
// ❌ Common failure: blue button with white text
<button className="bg-blue-400 text-white">Fails — 2.59:1</button>
// ✅ Pass
<button className="bg-blue-600 text-white">Passes — 4.57:1</button>
Automated Testing
Automated tools catch ~30–40% of WCAG issues. Use them in CI as a baseline, not a replacement for manual testing.
axe-core in Playwright:
// tests/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility', () => {
test('home page has no WCAG violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('product page is accessible', async ({ page }) => {
await page.goto('/products/example-product');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.exclude('.third-party-widget') // Exclude known third-party issues
.analyze();
if (results.violations.length > 0) {
console.log('Violations:', JSON.stringify(results.violations, null, 2));
}
expect(results.violations).toEqual([]);
});
test('modal is accessible when open', async ({ page }) => {
await page.goto('/pricing');
await page.click('[data-testid="open-contact-modal"]');
await page.waitForSelector('[role="dialog"]');
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});
Screen reader testing (manual — can't be automated): Test with NVDA + Firefox (Windows) and VoiceOver + Safari (Mac). Walk through key user journeys — sign up, checkout, account management — listening only to the screen reader with the monitor off.
Target Size (WCAG 2.5.8 — New in 2.2)
Interactive elements must be at least 24×24 CSS pixels:
// ❌ Icon buttons often fail target size
<button className="p-1">
<ChevronIcon className="w-4 h-4" /> {/* Total size: 24px — borderline */}
</button>
// ✅ Use padding to ensure minimum target size
<button className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
<ChevronIcon className="w-4 h-4" aria-hidden="true" />
<span className="sr-only">Next page</span>
</button>
The sr-only class (Tailwind) hides text visually while keeping it available to screen readers — the correct approach for icon-only buttons.
Accessibility Audit Cost
| Scope | Method | Cost | Findings |
|---|---|---|---|
| Automated scan only | axe-core / Lighthouse | $0 (tool cost) | 30–40% of issues |
| Manual expert audit (10 pages) | External auditor | $3,000–8,000 | 80–95% of issues |
| Full VPAT (for enterprise sales) | Certified auditor | $8,000–25,000 | Formal documentation |
| Ongoing monitoring | axe Monitor | $100–500/mo | Regression detection |
| Remediation (per page) | Developer time | $500–3,000/page | Depends on severity |
For SaaS products targeting enterprise customers, a VPAT (Voluntary Product Accessibility Template) is increasingly required in procurement. Enterprises with government funding often require WCAG 2.1 AA or Section 508 compliance.
Working With Viprasol
We build accessibility into products from the start — semantic HTML, keyboard navigation, ARIA patterns, and automated axe-core tests in CI. For existing products, we conduct accessibility audits and remediation sprints that bring applications to WCAG 2.2 AA compliance.
→ Talk to our team about accessibility implementation or auditing.
See Also
- Next.js Performance — Core Web Vitals includes accessibility signals
- Software Testing Strategies — integrating accessibility into your test suite
- Web Performance Optimization — semantic HTML benefits SEO and accessibility
- Hiring Software Engineers — accessibility as a hiring signal
- Web Development Services — accessible frontend development
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.