WCAG 2.2 Level AA in React and Next.js: The Engineering-Level Accessibility Audit Before EAA Enforcement
The compliance deadline is not a rumour. The European Accessibility Act requires digital products and services sold in the EU to meet WCAG 2.2 Level AA — and enforcement for B2B SaaS moved from "aspirational" to "enforceable" in mid-2025, with member states progressively activating penalty mechanisms through 2026. If your product is used by enterprise customers in Germany, France, the Netherlands, or Spain, procurement teams are already asking for accessibility conformance statements. Sales cycles are stalling.
The problem is that most engineering teams receive a WCAG 2.2 checklist that was written for people who audit websites with browser extensions, not for engineers maintaining a 200-component React codebase with a shared design system built on Radix UI and Headless UI. The checklist says "provide visible focus indicators." It does not say that your Next.js App Router navigation silently destroys focus position on every route transition. That is the gap this post addresses.
Why React and Next.js Create Accessibility Problems That Static Sites Do Not
WCAG was originally written against the mental model of a document that loads once. React applications are not documents — they are state machines that surgically update the DOM. This architectural mismatch produces a category of accessibility failures that browser extension scanners cannot detect and that most WCAG checklists do not describe precisely enough to fix.
Route transitions discard focus. In a traditional multi-page application, navigating to a new URL triggers a full page load, and screen readers announce the new page title automatically. In a Next.js App Router application, navigation is client-side. The DOM updates, the URL changes, but unless you explicitly manage focus, the screen reader cursor stays wherever it was. A keyboard user who clicked a link in the main navigation is now lost somewhere in the old page's DOM, with no announcement that anything changed.
Component library defaults are not accessible defaults. Radix UI and Headless UI provide excellent accessibility primitives, but "primitive" is the operative word — they expose the building blocks, not a finished accessible component. The Radix Dialog component correctly traps focus inside an open modal, but its onOpenAutoFocus prop defaults to focusing the first focusable element, which is often a close button. When a user opens a confirmation dialog, the first thing a screen reader announces is "Close button" — before the user knows what they are closing or confirming. That behaviour passes automated scanning and fails real-world usability.
Async state updates create timing gaps. Form validation errors in React are often rendered conditionally based on component state. The problem is that when React updates the DOM to show an error message, the screen reader has no automatic mechanism to announce it. If the error <span> is simply mounted into the DOM, a user relying on a screen reader may never hear it. Live regions, aria-describedby, and focus management must be coordinated deliberately — and the coordination has to survive re-renders.
The WCAG 2.2 Criteria That Bite React Teams
WCAG 2.2 added six new success criteria beyond WCAG 2.1. Two of them catch React codebases consistently.
2.5.7 Dragging Movements (Level AA)
Any functionality that uses a dragging movement must also be achievable without dragging. This sounds straightforward until you look at real product surfaces: resizable sidebar panels, drag-to-reorder list items, interactive timeline scrubbers, dashboard widget arrangement. Most component libraries implement these as pointer-event listeners that have no keyboard or single-pointer alternative. Running a WCAG 2.5.7 audit on a SaaS dashboard typically surfaces six to twelve violations in an afternoon.
The fix is not to remove drag functionality. It is to add a pointer-event alternative — typically keyboard controls for reorder operations (arrow keys with explicit move handlers) and accessible resize handles with role="separator" and aria-valuenow attributes for resizable panes.
2.4.11 Focus Not Obscured and 2.4.12 Focus Not Obscured (Enhanced)
The minimum criterion requires that a focused component is not entirely hidden by sticky headers, cookie banners, or notification toasts. The enhanced criterion requires that the focused element is fully visible. Sticky navigation bars in Next.js layouts obscure the focused element on scroll in a majority of real-world codebases. The canonical fix is scroll-margin-top on focusable elements sized to match the sticky header height, but this requires knowing the header height at scroll time — which in a responsive layout changes at breakpoints and can change dynamically if the header collapses.
A production solution uses a CSS custom property that the layout component sets and updates, and focusable components consume:
:root {
--sticky-header-height: 64px;
}
@media (max-width: 768px) {
:root {
--sticky-header-height: 48px;
}
}
*:focus {
scroll-margin-top: calc(var(--sticky-header-height) + 8px);
}
When the header height is dynamic — collapsing on scroll, for example — update the custom property with a ResizeObserver attached to the header element.
Focus Management in Next.js App Router
The App Router introduced layouts, nested routes, and streaming rendering — all of which create new challenges for focus management. The recommended pattern for route transitions in an App Router application is to render a visually hidden focus target at the top of each page and programmatically move focus to it after navigation completes, while simultaneously announcing the new page through an ARIA live region.
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function RouteAnnouncer() {
const pathname = usePathname();
const announcerRef = useRef<HTMLParagraphElement>(null);
const focusTargetRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
const title = document.title || pathname;
if (announcerRef.current) {
announcerRef.current.textContent = '';
requestAnimationFrame(() => {
if (announcerRef.current) {
announcerRef.current.textContent = `Navigated to ${title}`;
}
});
}
focusTargetRef.current?.focus({ preventScroll: true });
}, [pathname]);
return (
<>
<p
ref={announcerRef}
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
<h1
ref={focusTargetRef}
tabIndex={-1}
className="sr-only"
>
{/* Populated by the page layout */}
</h1>
</>
);
}
Place this component in your root layout. Screen reader users receive a consistent navigation announcement and a predictable focus landing point after every route change. The requestAnimationFrame wrapper on the live region update forces a re-announcement even when the page title is unchanged — without it, some screen readers suppress a repeated identical announcement.
Form Error Announcements That Work With Real Screen Readers
The naive implementation of form validation errors mounts an error <div> next to the failing field. This works visually and passes automated accessibility scanning. It fails with NVDA, JAWS, and VoiceOver because those screen readers do not announce DOM insertions unless they occur inside a designated live region.
The production pattern combines three mechanisms: an ARIA live region for announcements, aria-describedby linking the input to its error message, and programmatic focus moved to the first error on submission failure.
import { useId } from 'react';
interface FieldProps {
label: string;
error?: string;
value: string;
onChange: (v: string) => void;
}
export function AccessibleField({ label, error, value, onChange }: FieldProps) {
const inputId = useId();
const errorId = useId();
return (
<div>
<label htmlFor={inputId}>{label}</label>
<input
id={inputId}
aria-describedby={error ? errorId : undefined}
aria-invalid={!!error}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{error && (
<span id={errorId} role="alert">
{error}
</span>
)}
</div>
);
}
role="alert" creates an implicit live region with aria-live="assertive". Use it for validation errors only — assertive regions interrupt the screen reader mid-sentence, which is appropriate when the user cannot proceed but disruptive if overused for informational messages.
On form submission failure, focus moves to the first invalid field after React has committed its DOM update:
const firstErrorRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errors = validate(formData);
if (Object.keys(errors).length > 0) {
setErrors(errors);
requestAnimationFrame(() => {
firstErrorRef.current?.focus();
});
return;
}
// proceed with submission
};
The requestAnimationFrame is required. React batches and flushes state updates asynchronously, so calling focus() synchronously after setErrors moves focus before the error element exists in the DOM, and aria-describedby points to nothing.
Color Contrast Tokens Across Theme Switching
WCAG 1.4.3 requires a minimum 4.5:1 contrast ratio for normal text and 3:1 for large text and UI component boundaries. Maintaining this across light and dark themes is where most teams accumulate quiet failures — not because anyone made a careless decision, but because semantic tokens compose in ways that are not obvious until you measure them.
A muted text color that achieves 4.8:1 in light mode against a white background may drop to 2.9:1 in dark mode against a near-black surface. The mistake is verifying tokens individually rather than verifying the ratios that emerge from composition.
Build contrast validation into your design token pipeline:
import { wcagContrast } from 'culori';
const pairs = [
{ fg: tokens.light['color-text-primary'], bg: tokens.light['color-surface'] },
{ fg: tokens.dark['color-text-primary'], bg: tokens.dark['color-surface'] },
{ fg: tokens.light['color-text-muted'], bg: tokens.light['color-surface'] },
{ fg: tokens.dark['color-text-muted'], bg: tokens.dark['color-surface'] },
];
for (const { fg, bg } of pairs) {
const ratio = wcagContrast(fg, bg);
if (ratio < 4.5) {
console.error(`CONTRAST FAIL: ${fg} on ${bg} = ${ratio.toFixed(2)}:1`);
process.exit(1);
}
}
Run this as part of your token build step in CI. Contrast failures become build failures before they reach a staging environment. This is considerably faster than discovering them in a manual audit under procurement pressure.
The CI Setup: axe-core and Playwright
Manual testing and browser extension scanning find a fraction of real accessibility issues. The combination of axe-core and Playwright finds substantially more — automatically, on every pull request.
npm install --save-dev @axe-core/playwright axe-playwright
Write tests that run axe against your application's actual rendered pages, including authenticated states and open interactive components:
import { test } from '@playwright/test';
import { checkA11y } from 'axe-playwright';
test('dashboard passes WCAG 2.2 AA audit', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await checkA11y(page, undefined, {
axeOptions: {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'] },
},
detailedReport: true,
detailedReportOptions: { html: true },
});
});
test('confirmation dialog passes axe when open', async ({ page }) => {
await page.goto('/settings');
await page.click('[data-testid="delete-account-button"]');
await page.waitForSelector('[role="dialog"]');
await checkA11y(page, '[role="dialog"]', {
axeOptions: {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'] },
},
});
});
Axe catches approximately 30–40% of WCAG violations automatically — colour contrast failures, missing alternative text, unlabelled form inputs, landmark region violations, and the more mechanical WCAG 2.2 criteria. The CI report shows exactly which component triggered the failure, which criterion it violates, and the element's DOM position. A developer can act on that without an accessibility specialist in the loop.
The Manual Testing Your CI Cannot Replace
Automated tools test structure, not experience. Three manual testing steps cover what axe cannot.
Test with a real screen reader. NVDA on Windows with Firefox, VoiceOver on macOS with Safari, and TalkBack on Android with Chrome cover the screen reader and browser combinations your users are likely using. Navigate your application's critical paths without a mouse. Announcement quality, focus order, and the coherence of what a screen reader conveys can only be assessed by listening.
Test keyboard-only navigation. Tab through every interactive element in sequence. Confirm that focus is always visible, never unintentionally trapped, and always returned to a logical position when a modal, drawer, or popover closes.
Test at 400% zoom with reflow enabled. WCAG 1.4.10 requires that content remains usable at 400% zoom on a 1280px viewport without horizontal scrolling. Sticky headers collapse, navigation moves to a mobile layout, and most modals break at this zoom level. Test your most complex surfaces — dashboards, data tables, multi-step forms — and verify that all functionality remains reachable.
Getting Started Without Stopping the Roadmap
A full WCAG 2.2 AA remediation of a mature React codebase is measured in weeks, not days. Prioritise by user impact and sales cycle risk. Route transition focus management affects every keyboard and screen reader user on every navigation — fix it first. Form error announcements block task completion and are the next most impactful. Component-level issues in the design system come after, starting with interactive components on your highest-traffic pages.
Document each fix as you go. Procurement teams increasingly ask for a VPAT or a conformance statement, and your pull request history — structured correctly — becomes the audit trail that supports it.
Accessibility remediation in complex React and Next.js applications is part of the application development and code quality work we do with B2B SaaS teams. If EAA enforcement is now on your roadmap and you need an independent engineering assessment of your WCAG 2.2 gaps — and a realistic estimate of what remediation will take — reach out at hello@wolf-tech.io or visit wolf-tech.io.
Frequently Asked Questions
Does WCAG 2.2 replace WCAG 2.1 completely?
WCAG 2.2 is backwards-compatible — all WCAG 2.1 success criteria are included in 2.2. Meeting WCAG 2.2 Level AA means you also meet WCAG 2.1 Level AA. For EAA purposes, the relevant harmonised standard is EN 301 549, which references WCAG 2.1 but in practice is now interpreted to include WCAG 2.2 criteria in conformance assessments.
Does axe-core catch all WCAG 2.2 criteria automatically?
No. Axe reliably catches structural violations: missing labels, invalid ARIA usage, contrast below threshold, missing landmarks. It cannot test whether a route transition manages focus correctly, whether a drag interaction has a keyboard alternative, or whether a screen reader announces form errors in a usable sequence. Those require manual or Playwright-orchestrated testing.
Our design system uses Radix UI. Are we covered on accessibility?
Radix implements ARIA patterns correctly at the primitive level, but "correctly implemented primitive" is not the same as "accessible component in context." Focus handling on dialog open, keyboard navigation in composite widgets, and announcement quality all depend on how your team assembles and configures the primitives. A Radix-based design system can be fully WCAG 2.2 AA compliant, but it requires deliberate work on top of what Radix provides by default.
How long does a WCAG 2.2 AA remediation typically take for a mature Next.js SaaS?
For a product with 50–150 unique UI components and no prior accessibility investment, a realistic estimate is 6–10 weeks of engineering time for a developer working primarily on remediation, assuming the design token system does not require a full overhaul. Route transition and form error fixes are typically a few days. Component-level issues — custom modal focus management, keyboard support for drag interactions, complex data table navigation — account for most of the remaining time.
Will our existing Lighthouse accessibility score tell us if we pass WCAG 2.2 AA?
Not reliably. Lighthouse runs axe under the hood and surfaces a subset of automated checks. A Lighthouse accessibility score of 100 is consistent with dozens of real WCAG failures that automated tools cannot detect. Use Lighthouse as a floor check, not a compliance signal.

