Accessible React
Every React app renders to the same HTML the browser has always shipped, and every assistive tool works from the DOM your components produce. Accessibility in React is mostly a series of small choices about that DOM: which element you render, how it gets its name, and what you do when the screen changes underneath someone who cannot see it change.
One JSX detail before the rest. React renames class to className and for to htmlFor, but ARIA attributes keep their hyphens: aria-live, aria-label, and a plain role.
Semantic elements come first
A <button> arrives with a pile of behavior already attached. It sits in the tab order, so a keyboard reaches it. It fires its click handler on Enter and on Space. A screen reader announces it as a button and reads its text as the name, which is also the name voice control software targets. The browser handles the disabled state, the focus ring, and the active styling.
// the browser gives you focus, keyboard activation, and the "button" announcement
<button className="die" onClick={hold}>{value}</button>A <div> carrying an onClick handler gets one item from that list: the click. Tab skips past it, Enter and Space do nothing, and a screen reader reads it as a run of text with no hint that anything will happen if you interact with it.
The usual patch is role="button" plus tabIndex={0}, which puts the element in the tab order and changes what gets announced. Behavior is still missing. You would add an onKeyDown handler, check for Enter and for Space, call preventDefault() on Space so the page stops scrolling, and then keep a hand-rolled disabled state in sync with the styling. That is a fair amount of code to rebuild something the browser already ships. Reaching for the real <button> is the shorter path, and it stays correct as browsers change.
The same logic runs through the rest of the markup: <a href> for navigation, <nav> and <main> as landmarks a screen reader can jump between, headings in order for the outline people navigate by. Most accessibility work in a React codebase is choosing the element that already does the job.
Announcing what changed
A single-page app updates in place. There is no page load to tell a screen reader that something happened, so a change rendered halfway down the screen can be completely silent. A live region hands that information over: a container the screen reader watches and announces whenever its contents change. The sr-only class below hides it visually, using a CSS pattern covered later in this chapter.
<div aria-live="polite" className="sr-only">
{isGameWon && <p>You won! Press New Game to start again.</p>}
</div>The wrapper renders every time, empty at first, and React swaps a paragraph into it when isGameWon flips. That ordering is the part people get wrong. The element carrying aria-live has to be in the DOM before the content arrives, because screen readers register live regions when they encounter them and then watch for mutations. Mount the region and its text together in a single render and many screen readers announce nothing at all: the whole thing looks like ordinary new content. Keeping an empty region in the tree costs nothing and makes the announcement reliable.
aria-live="polite" puts the announcement in a queue. The screen reader finishes whatever it is currently reading, then delivers your message at the next natural pause, which may land a beat after the visual change. That delay is deliberate, and polite is the right setting for almost everything.
Keyboard interaction
Tab moves forward through focusable elements, Shift+Tab moves back, Enter activates links and buttons, and Space activates buttons and toggles checkboxes.
Tab order follows DOM order, so the sequence your JSX renders is the sequence people move through. Reordering visually with CSS leaves a tab order that jumps around the screen, and positive tabIndex values cause the same confusion on purpose. tabIndex={-1} is the useful one: it makes an element focusable from JavaScript while keeping it out of the tab sequence, which is what a focus target like a dialog heading needs.
Two more rules. Keep focus visible: avoid outline: none unless a :focus-visible style of your own replaces it. And keep an exit available: a modal that deliberately holds focus inside itself needs Escape to close and needs to hand focus back to its trigger.
Moving focus deliberately
When the UI changes shape, focus can end up nowhere. Someone activates a button, the button is removed or replaced, and focus falls back to <body>. The next Tab starts at the top of the page, and the reader has lost their place.
The fix is to move focus somewhere sensible, which is one of the legitimate uses of a ref:
function NewGameButton({ isGameWon, onNewGame }) {
const buttonRef = useRef(null)
useEffect(() => {
if (isGameWon) {
buttonRef.current.focus()
}
}, [isGameWon])
return <button ref={buttonRef} onClick={onNewGame}>New Game</button>
}The effect runs after React has committed that node to the screen, so the element is there to receive focus. Guarding on isGameWon keeps it from stealing focus on every render.
The same pattern covers the other common moments: a dialog takes focus on open and hands it back to the trigger on close, a validation failure sends focus to the first invalid field, deleting a row moves focus to the row that replaced it. The rule underneath is one line: if your code removed the thing that had focus, your code decides where focus goes next.
Visually hidden text
Plenty of status is obvious from the layout and silent to a screen reader: a green check beside a field, a die that looks pressed, a number that reads clearly from where it sits. Visually hidden text spells that out for anyone listening to the page.
The convention is a class called sr-only. It has no meaning to React or to the browser: it is a plain class name, and these CSS rules are what do the work.
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}The element stays in the accessibility tree while taking up no visual space. display: none and visibility: hidden would strip it from that tree too, hiding it from everyone.
An icon-only button is the everyday case. Either give it an aria-label, or put real text inside and hide it visually:
<button onClick={onClose}>
<XIcon aria-hidden="true" />
<span className="sr-only">Close</span>
</button>aria-hidden="true" keeps the decorative SVG out of the announcement, and the hidden span supplies the name. One caution about aria-label: it sets an accessible name on interactive elements and on anything carrying an explicit role, and browsers frequently ignore it on a plain <div> or <span> with no role. Keep it to buttons, links, inputs, and labeled landmarks.
Reading the source will only take you so far with any of this. Turn VoiceOver on with Cmd+F5 and listen to your own app, and run axe DevTools in the browser to catch missing labels and unnamed controls automatically.
button when something is clickable, and a real label next to every input. Those elements come with keyboard support and a name that a screen reader can read, all for free. When something changes on screen that a person listening to the page would otherwise miss, put a short sentence inside a div with aria-live="polite", and keep that div on the page from the start so the change is noticed. Next up: Beyond the basics, a map of what comes after the fundamentals.

