WCAG 2025 Accessibility Guide for React Developers
Everything React developers need to know about WCAG 2.2 and the upcoming 3.0 — from ARIA patterns to focus management, with real component code.
Why Accessibility Still Bites React Developers in 2025
Let's be honest — React's component model makes it genuinely easy to build inaccessible UIs without realising it. You compose a <div> with an onClick, style it to look like a button, and ship it. Works great with a mouse. Completely broken for anyone using a keyboard or screen reader.
Honestly, the gap between "looks accessible" and "is accessible" widened as design trends got more creative. When you're building with glassmorphism layers or complex motion components from Empire UI, the default semantic HTML you'd normally rely on gets buried under a dozen wrapper divs and animation containers.
WCAG 2.2 became an official W3C recommendation in October 2023. Most legal frameworks — ADA, EN 301 549, AODA — now reference it. You'd be surprised how many teams are still auditing against 2.1 criteria and wondering why they're getting compliance flags. The new success criteria aren't scary, but you do need to know they exist.
Worth noting: WCAG 3.0 is still a Working Draft as of 2025, but its new "outcome" model is already influencing how accessibility auditors write their reports. Start reading it now so the final spec doesn't blindside you.
The WCAG 2.2 Criteria React Devs Actually Fail
There are four new success criteria in 2.2 that trip up React apps specifically. Focus Not Obscured (2.4.11) is the biggest one — your sticky headers and fixed bottom nav bars? They can't cover the focused element. A 64px fixed header pushing content down but not adjusting scroll padding will fail this criterion instantly.
Focus Appearance (2.4.12) is an enhanced criterion and technically AA in 3.0 territory, but auditors are checking it now. Your focus ring needs a minimum area of the perimeter of the component multiplied by 2px. That vague :focus { outline: 2px solid blue } you copy-pasted in 2019 might not cut it anymore.
Dragging Movements (2.5.7) requires that anything you can drag can also be operated by a single pointer without dragging. Think sliders, sortable lists, drag-to-dismiss cards. If your component library doesn't offer a keyboard-equivalent path for those interactions, you own that problem.
Quick aside: Accessible Authentication (3.3.8) means you can't require users to transcribe characters from an image or solve a cognitive puzzle without an accessible alternative. If you're using reCAPTCHA v2 anywhere, fix that.
ARIA Done Right — The Patterns That Actually Matter
ARIA is powerful and easy to misuse. The first rule of ARIA is literally "don't use ARIA" — meaning, use native HTML elements whenever possible. A <button> doesn't need role="button". A <nav> doesn't need aria-label="navigation" unless you have multiple navs on the page.
That said, React components that render custom interactive widgets absolutely do need ARIA. Here's a disclosure widget pattern that's clean and keyboard-accessible:
function Disclosure({ summary, children }) {
const [open, setOpen] = React.useState(false);
const contentId = React.useId();
return (
<div>
<button
aria-expanded={open}
aria-controls={contentId}
onClick={() => setOpen(prev => !prev)}
>
{summary}
</button>
<div
id={contentId}
hidden={!open}
role="region"
aria-label={summary}
>
{children}
</div>
</div>
);
}Notice React.useId() — that's not just ergonomics. Hardcoded IDs break the moment you render the same component twice on a page. useId was added in React 18 and you should be using it for all ID-to-aria associations. One more thing — hidden is better than display: none via CSS here because it removes the element from the accessibility tree entirely when closed, which is exactly what you want.
For complex widgets like comboboxes, trees, and data grids, don't hand-roll ARIA. Use Radix UI or Headless UI as a foundation, then layer your visual design on top.
Focus Management in React Single-Page Apps
Focus management is where most SPAs fall apart. When you navigate between routes, focus stays wherever it was — usually a link in the previous page's nav. Screen reader users have no idea a page transition happened.
The fix is straightforward. After a route change, move focus to a skip-link, an <h1>, or a dedicated focus sentinel. React Router v6.4+ has built-in scroll restoration, but focus management is still manual. Here's a minimal pattern:
function PageFocusSentinel({ title }) {
const ref = React.useRef(null);
const location = useLocation();
React.useEffect(() => {
ref.current?.focus();
}, [location.pathname]);
return (
<h1
ref={ref}
tabIndex={-1}
style={{ outline: 'none' }}
>
{title}
</h1>
);
}In practice, tabIndex={-1} on the <h1> is the right move — it makes the element programmatically focusable without inserting it into the tab order. The outline: none is acceptable here because this focus is programmatic, not user-initiated. Just don't do that for interactive elements.
Modal dialogs need extra care. When a modal opens, trap focus inside it using something like focus-trap-react. When it closes, return focus to the element that triggered it. This isn't a suggestion — failing to do this fails WCAG 2.4.3 (Focus Order) and will get flagged in any serious audit.
Color Contrast and Visual Design — The Numbers That Matter
WCAG 2.2 AA requires a 4.5:1 contrast ratio for normal text and 3:1 for large text (18pt regular or 14pt bold, which maps to roughly 24px and 18.67px in CSS). UI components and informational graphics need 3:1 against adjacent colors.
Look, if you're building with dark glassmorphism aesthetics, contrast is genuinely your biggest risk. A frosted card with white text over a gradient background shifts its effective contrast ratio depending on what's behind the card at runtime. That's hard to test statically. You need to test it against your actual darkest and lightest background states. The glassmorphism generator on Empire UI shows live contrast ratios as you adjust opacity — use that when prototyping.
Non-text contrast catches a lot of teams off guard. Your icon buttons, form field borders, and focus indicators all need 3:1 against their backgrounds. That 1px light-gray border on your input? Almost certainly fails. Bump it to 2px and darken the color.
WCAG 3.0 is proposing APCA (Advanced Perceptual Contrast Algorithm) to replace the current luminance formula. APCA is more accurate for real-world readability, but it's not normative yet. Worth experimenting with now — the Colour Contrast Analyser tool supports both modes.
Testing Your React App Against WCAG
Automated tools catch about 30-40% of accessibility issues. That's not nothing, but it means you can pass every automated check and still have a broken experience. You need a layered testing approach.
Start with axe-core via @axe-core/react in development mode — it logs violations to the console without blocking the build. For CI, use jest-axe to assert zero violations on your component renders. Here's the setup:
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
test('Disclosure has no axe violations', async () => {
const { container } = render(
<Disclosure summary="What is WCAG?">
A web content accessibility guideline.
</Disclosure>
);
expect(await axe(container)).toHaveNoViolations();
});That said, manual keyboard testing is non-negotiable. Tab through your entire app. Can you reach every interactive element? Does the tab order make logical sense? Does focus never disappear into a void? Do modals trap focus correctly? You can run this test in under 10 minutes and catch issues no linter will ever find.
For screen reader testing, NVDA + Firefox on Windows and VoiceOver + Safari on macOS cover the vast majority of real-world users. Test each major user flow — not just individual components — because screen reader behavior depends heavily on context and page structure. If your templates include complex interactive layouts, run them through VoiceOver before shipping.
Building an Accessible Design System From the Start
If you're building a design system in 2025, bake accessibility in at the token level. Your color tokens should ship with contrast-ratio metadata. Your spacing tokens should map to minimum touch target sizes — WCAG 2.5.5 requires 44x44px for most interactive elements, though 2.5.8 (new in 2.2) allows 24x24px with sufficient spacing.
Document accessibility requirements in your component API. If a component requires an aria-label prop when used without visible text, make it required in your TypeScript interface. Lint rules like eslint-plugin-jsx-a11y will catch a lot at write-time, but typed props make the contract explicit to every consumer of your design system.
Honestly, the teams that ship the most accessible products aren't doing heroic remediation sprints — they're treating a11y as a first-class constraint at component design time, the same way they treat responsive breakpoints. If you're starting fresh and want a solid reference for how a component library handles visual design + accessibility together, browse the components to see how Empire UI approaches interactive states and focus styles across different design systems like neumorphism and neobrutalism.
One more thing — accessibility isn't a one-time audit. It's a maintenance discipline. Pin axe-core in your CI, schedule quarterly keyboard walkthroughs, and include a11y acceptance criteria in every new component ticket. That's how it actually sticks.
FAQ
WCAG 2.2 adds nine new success criteria, mainly around focus appearance, dragging interactions, accessible authentication, and target sizes. If you're already compliant with 2.1 AA, you're probably failing two or three of the new ones — Focus Not Obscured and Dragging Movements are the most common gaps in React apps.
Not for compliance — it's still a Working Draft and won't be normative for a few years. But the APCA contrast algorithm and outcome-based scoring model are already influencing audits, so it's worth understanding the direction things are heading.
Automated tools catch maybe 35-40% of issues — the rest require manual testing. Run keyboard-only navigation through every user flow, then verify critical paths with VoiceOver or NVDA. There's no substitute for it.
The new 2.5.8 criterion (AA) requires at least 24x24 CSS pixels with enough spacing around the target. The enhanced 2.5.5 (AAA) still requires 44x44px. If you're targeting AA compliance, 24px minimum with proper spacing is the floor.