Accessibility in React Apps
- react
- accessibility
- a11y
- typescript
- nextjs
- frontend
- javascript
- web-development
- ux
- semantic-html
In Zustand for Client State, I covered how to manage state that lives outside the server, modals, toasts, form drafts, without reaching for a heavier library than the problem needs. This post covers a different kind of state: making sure that UI is actually usable by people who aren't navigating it with a mouse and eyes that can see color contrast clearly.
This is part of the React & Next.js Complete Guide series, which builds up from React Fundamentals for Professionals toward production Next.js apps. Accessibility tends to get treated as a checkbox before a compliance audit, or a feature bolted on after launch. Neither works well, because most accessibility problems in React apps come from architectural decisions: how you structure components, what you render as a div versus a semantic element, and whether focus is something you actively manage or leave to chance.
I want to walk through the parts of accessibility specific to React, where the component model actively works against you if you're not paying attention, and the pragmatic line between "this app is accessible" and "this app passes an automated scanner."
Why accessibility is an architecture problem, not a CSS problem
The instinct when accessibility comes up late in a project is to treat it as a styling pass: bump the contrast ratios, add some aria-label attributes, call it done. That gets you partway, but it misses where the real damage happens.
React's component model encourages composing UI out of generic building blocks. A button becomes a <div onClick={...}> styled to fight the browser's default appearance. A modal becomes a <div> layered on top of the page because <dialog> felt like extra research for no clear payoff. Each choice is locally reasonable and globally expensive, because you've opted out of a decade of browser and assistive-technology behavior that came free with the native element: keyboard activation, focus handling, role semantics screen readers already know how to announce.
A one-time lint rule won't fix this. Make the native element the default: reach for it first, and only replace it with a custom-styled equivalent once you've replicated the behavior it gave you for free. A custom <div> button needs role="button", tabIndex={0}, and a keydown handler for Enter and Space, in addition to the click handler. That's easy to forget, and it's the kind of gap that never shows up in your own manual testing because you're always using a mouse.
Semantic HTML and the component abstraction tax
The place this bites hardest is component libraries. Building a Card component that wraps a clickable area, it's tempting to make the whole thing a div with an onClick, since that's simpler to style than a button or an a sized to fill the card.
// Avoid: the whole card is a click target with no keyboard access
function ProjectCard({ project }: { project: Project }) {
const router = useRouter();
return (
<div className="card" onClick={() => router.push(`/projects/${project.id}`)}>
<h3>{project.name}</h3>
<p>{project.description}</p>
</div>
);
}
This looks fine visually and works fine with a mouse. It's invisible to a keyboard user, because a div isn't in the tab order and has no default interaction semantics. The fix is to keep the navigation semantics on an actual link, and let CSS handle making the whole card feel clickable.
import Link from "next/link";
function ProjectCard({ project }: { project: Project }) {
return (
<Link
href={`/projects/${project.id}`}
className="card block focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
>
<h3>{project.name}</h3>
<p>{project.description}</p>
</Link>
);
}
This version is reachable by Tab, activatable by Enter, and announced correctly as a link with the card's text content. It also works with "open in new tab" and other native link behaviors that a click handler doesn't replicate without extra work. The tradeoff is styling an <a> to look like a block-level card, which occasionally fights with existing CSS resets, but that's a smaller cost than reimplementing keyboard and screen reader behavior by hand.
The same reasoning applies to headings. A <div> styled to look like an <h2> gives you visual hierarchy but nothing structural: a screen reader user navigating by heading, a common pattern most screen readers support with a dedicated shortcut, never sees it. Use the semantic element, then override its default styles if the built-in look doesn't match your design system.
Managing focus in a component-driven app
Focus management is genuinely React-specific, because React apps route and mount content dynamically in ways a server-rendered multi-page app didn't have to think about.
When a modal opens, focus needs to move into it, and return to the triggering element when the modal closes. Get this wrong and a keyboard user tabs into a page that visually shows a modal but whose focus is still sitting on a button underneath it, invisible and disorienting.
import { useEffect, useRef } from "react";
function Modal({
isOpen,
onClose,
children,
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
triggerRef.current = document.activeElement as HTMLElement;
dialogRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
triggerRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
className="modal"
>
{children}
</div>
);
}
This handles the two moments that matter most: focus moves in on open, and returns to whatever triggered it on close, including on Escape. What it doesn't handle is focus trapping, keeping Tab from escaping the dialog into the page behind it. That's worth adding once you have more than one modal in the app, at which point a maintained primitive like Radix UI's Dialog beats hand-rolling trap logic everywhere. Hand-rolled traps are a common source of bugs: an edge case where the last focusable element is conditionally rendered, and Tab silently escapes.
The same pattern applies to route changes in Next.js. A client-side navigation doesn't reload the page, so the browser's default behavior of resetting focus never happens. If you don't manage it, focus stays wherever it was on the previous page, disorienting for a screen reader user expecting to land somewhere sensible, typically the new page's main heading.
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
function RouteAnnouncer() {
const pathname = usePathname();
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
headingRef.current?.focus();
}, [pathname]);
return (
<h1 ref={headingRef} tabIndex={-1} className="sr-only-until-focused">
Page content
</h1>
);
}
Treat this as a starting point. In practice you'd render it per page with the actual page title, paired with a visually hidden live region that announces the page change to screen reader users who aren't currently focused on the heading.
Forms, labels, and error messaging that actually gets announced
Form accessibility fails most often because a label and its input aren't programmatically connected, not because the label is missing entirely. A <label> sitting next to an <input> looks correct visually and is useless to a screen reader unless it's tied together with htmlFor and a matching id, or the input is nested inside the label.
The other common gap is error messaging. A red border and a message rendered elsewhere on the page communicates nothing to a screen reader user unless the input is explicitly associated with that error text.
function EmailField({
value,
onChange,
error,
}: {
value: string;
onChange: (value: string) => void;
error?: string;
}) {
const inputId = "email";
const errorId = "email-error";
return (
<div className="field">
<label htmlFor={inputId}>Email address</label>
<input
id={inputId}
type="email"
value={value}
onChange={(event) => onChange(event.target.value)}
aria-invalid={Boolean(error)}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<p id={errorId} role="alert" className="field-error">
{error}
</p>
)}
</div>
);
}
aria-describedby connects the input to its error text for a screen reader, and aria-invalid communicates the invalid state itself, independent of whatever color signals it visually. role="alert" on the error message means it gets announced as soon as it appears, without the user navigating to it manually. This matters more than it looks: a form that validates on blur and shows an error only visually will pass an automated contrast checker and still be completely silent to a screen reader user who has no idea why submission failed.
The gap between automated scanners and real usability
Tools like axe-core, eslint-plugin-jsx-a11y, and Lighthouse's accessibility audit catch a meaningful chunk of problems: missing alt text, insufficient contrast, missing form labels, invalid ARIA usage. Running these in CI is worth doing, and eslint-plugin-jsx-a11y catches issues at write time rather than after a page renders.
npm install --save-dev eslint-plugin-jsx-a11y
{
"extends": ["plugin:jsx-a11y/recommended"]
}
What none of these tools catch is whether the experience of using your app with a keyboard or a screen reader actually makes sense. Automated tools can tell you an image is missing an alt attribute; they can't tell you the alt text you wrote is meaningless ("image123.png"), or that your custom dropdown, despite having all the right ARIA attributes, is confusing to navigate because the roving tabindex logic has a bug. Passing an automated scan only tells you the floor is solid. Closing the gap above it means navigating your app with a keyboard only, no mouse, for the primary flows, and ideally with a screen reader (VoiceOver on macOS or NVDA on Windows, both free) for anything complex like a data table or a custom widget.
This is also where I'd push back on deferring accessibility until "later." Retrofitting semantic structure onto a component library that's already shipped a hundred div-as-button instances is a much bigger job than building it correctly from the start. By the time you notice, the pattern is copy-pasted across the codebase. The earned-complexity principle that applies to most things in this series, don't build what you don't need yet, doesn't apply well here: the native-element-first default costs nothing extra up front and saves a rewrite later.
Key takeaways
- Prefer native semantic elements (`button`, `a`, `label`, `dialog`, headings) over generic `div`/`span` plus custom styling; they come with keyboard and screen reader behavior you'd otherwise reimplement.
- Focus management is React-specific: handle it explicitly for modals (move in on open, restore on close) and for client-side route changes (move focus to the new page's heading).
- Connect form errors to their inputs with `aria-describedby` and `aria-invalid`, and use `role="alert"` so errors get announced without manual navigation.
- Run `eslint-plugin-jsx-a11y` and `axe-core` in CI as a baseline, but treat them as a floor; they can't verify a custom widget's keyboard interaction actually makes sense.
- Reach for a maintained primitives library (Radix UI, React Aria) once you need patterns like focus traps or roving tabindex, instead of hand-rolling them repeatedly.
- Build accessibility in from the start. Retrofitting semantic structure across an already-shipped codebase costs far more than the small upfront cost of doing it correctly.
Frequently asked questions
Do I need to test with a real screen reader, or is an automated scanner enough?
A scanner catches structural problems, missing labels, bad contrast, invalid ARIA, but it can't tell you whether a component's interaction makes sense to someone navigating by keyboard or screen reader. Manual testing with VoiceOver or NVDA on your primary flows is the only way to catch that.
Is it acceptable to use `div` with `onClick` if I add the right ARIA attributes?
It works, but it's more code and more surface area for bugs than the native element, which needs `role="button"`, `tabIndex={0}`, and separate click and keydown handlers to match. Reserve custom elements for cases no native one fits.
How do I handle focus when a page navigates client-side in Next.js?
Since client-side navigation skips the browser's default focus reset, move focus explicitly to the new page's main heading on route change. Pair that with a visually hidden live region announcing the page title for users who don't notice the shift.
Should I build my own focus trap for modals, or use a library?
Hand-rolling one is fine for a single simple modal, but it's a common source of bugs once you have several with different content shapes. Past one modal, a maintained primitive like Radix UI's `Dialog` is worth the dependency.
Does dark mode or high contrast mode affect accessibility work?
Yes, but it's separate from the structural issues covered here. Check contrast ratios in every theme, and never use color alone to signal state; pair it with an icon, text, or `aria-invalid`.
Where does accessibility fit in code review, given how easy it is to skip?
Treat it like a missing test for a critical path: block on it in review. `eslint-plugin-jsx-a11y` catches a good chunk of issues automatically at PR time, which removes most of the friction of checking manually.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.