Accessibility in React
By the end of this lesson
Build interactive components that work with keyboard and screen readers.
React renders to the DOM, so the accessibility of a React application is the accessibility of the HTML it produces. The framework neither grants it nor prevents it. What it does is make it easy to build a control out of the wrong elements, because a div is as convenient to render as a button.
Two things carry most of the weight. The elements you choose, because browsers already implement keyboard behaviour, focus and announcement for the standard ones. And focus, because focus is how a keyboard user knows where they are and a screen reader user knows what is being read.
This lesson is deliberately last in the course, not because accessibility comes last, but because it needs the state, effect and composition ideas that came before. Earlier lessons already used labelled inputs and real buttons rather than saving them for here.
A div with an onClick is not a button. Here is what the browser gives you for free on the right, and what you owe it on the left:
| div with onClick | button element | |
|---|---|---|
| Reachable by Tab | No, unless you add tabIndex={0} | Yes |
| Activates on Enter and Space | No, unless you handle both keys yourself | Yes, including the platform's own conventions |
| Announced as | Nothing — a screen reader reads the text with no role | "Button", so the user knows it can be pressed |
| Disabled state | You must block the handler and convey the state some other way | disabled removes it from the tab order and is announced |
| Submits a form | No | Yes, and Enter in a text field triggers it |
| Code needed to behave correctly | A role, a tabIndex, two key handlers, and a disabled path | None |
| Where it still makes sense | A container that happens to respond to clicks in addition to a real control inside it | Anything the user is meant to activate |
"use client";
import { useEffect, useRef } from "react";
type Employee = { id: string; name: string; jobTitle: string; department: string };
export function EmployeeDetailDialog({
employee,
onClose,
}: {
employee: Employee;
onClose: () => void;
}) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
const openerRef = useRef<Element | null>(null);
useEffect(() => {
// Remember what had focus, then move focus into the dialog.
openerRef.current = document.activeElement;
closeButtonRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
// Put focus back where the user left it.
if (openerRef.current instanceof HTMLElement) {
openerRef.current.focus();
}
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="employee-dialog-title"
>
<h2 id="employee-dialog-title">{employee.name}</h2>
<p>
{employee.jobTitle}, {employee.department}
</p>
<button type="button" ref={closeButtonRef} onClick={onClose}>
Close
</button>
</div>
);
}- document.activeElement is read before focus moves, so the component knows which button opened it. Storing it in a ref rather than state is deliberate: it is not rendered, so changing it should not trigger a render.
- Focus moves to the close button on open. Without this, a keyboard user is still positioned behind the dialog and has to Tab forwards to reach content that appeared in front of them.
- The cleanup function returns focus to the opener. Skipping this is the more common bug: the dialog closes and focus falls back to the top of the document, so the next Tab starts from the beginning of the page.
- Escape closing the dialog is expected behaviour for anything that overlays the page. The listener is on the document because focus may be anywhere inside the dialog.
- role="dialog" with aria-modal="true" tells assistive technology this is a dialog and that content behind it is not currently relevant. aria-labelledby points at the heading, which is how the dialog gets its accessible name without repeating the text.
- One piece is missing on purpose: Tab can still leave the dialog and reach the page behind it. Keeping focus inside needs either the native dialog element with showModal, which the browser traps for you, or a well-tested library. Hand-written focus traps are where this pattern usually goes wrong.
Focus is a small number of moments. These are the four that get missed in React applications:
- When something opens
- A dialog, a drawer or an expanded panel should move focus into itself, usually to the first control or the heading. Otherwise focus stays behind the thing that just appeared.
- When something closes
- Focus returns to the control that opened it. If that control has gone — a delete button on a row that no longer exists — choose a sensible nearby target, such as the list heading, and make sure it can receive focus.
- After a client-side navigation
- A full page load resets focus to the top of the document. A client-side route change does not: focus stays wherever it was, so the next Tab continues from the old page's position and nothing announces that the page changed. Next.js handles part of this, but any custom route transition needs checking by hand with a keyboard and a screen reader.
- When focus is visible
- A keyboard user needs to see where focus is. Removing the browser's focus ring because it looks untidy makes the application unusable without a mouse. Replace it with something that meets contrast requirements instead, using the :focus-visible selector so it shows for keyboard use without appearing on every mouse click.
- Where focus should not be moved
- Do not move focus in response to data arriving or a timer firing. Focus jumping while someone is typing loses their input and their place. Async changes are announced, not focused — which is what the next section is for.
"use client";
import { useEffect, useState } from "react";
type Employee = { id: string; name: string; department: string };
type Status = "idle" | "loading" | "error";
export function EmployeeSearch() {
const [term, setTerm] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [results, setResults] = useState<Employee[]>([]);
useEffect(() => {
if (term.trim() === "") {
setResults([]);
setStatus("idle");
return;
}
const controller = new AbortController();
setStatus("loading");
fetch("/api/employees?search=" + encodeURIComponent(term), {
signal: controller.signal,
})
.then((response) => {
if (!response.ok) throw new Error("Search failed");
return response.json();
})
.then((employees: Employee[]) => {
setResults(employees);
setStatus("idle");
})
.catch((error) => {
if (error.name !== "AbortError") setStatus("error");
});
return () => controller.abort();
}, [term]);
return (
<search>
<label htmlFor="directory-search">Search employees by name</label>
<input
id="directory-search"
type="search"
value={term}
onChange={(event) => setTerm(event.target.value)}
/>
{/* Present from the first render, so changes to its text are announced. */}
<p role="status">
{status === "loading" && "Searching"}
{status === "error" && "Search could not be completed. Try again."}
{status === "idle" && term !== "" && results.length + " employees found"}
</p>
<ul>
{results.map((employee) => (
<li key={employee.id}>
{employee.name} ({employee.department})
</li>
))}
</ul>
</search>
);
}- role="status" marks a live region with polite announcement: a screen reader reads the new text when the user is between utterances, without interrupting. aria-live="polite" on the element does the same job if you prefer to be explicit.
- The paragraph is in the DOM from the first render, with no text in it. This matters more than it looks: many screen readers do not announce a live region that is added to the page at the same moment as its content, so conditionally rendering the whole element loses the announcement.
- Keep the live region small. Wrapping the whole results list in one means every row is read out on every change, which is unusable for a list of forty employees. A count is enough; the list itself is there to be navigated.
- The text is visible, not hidden. Everyone benefits from knowing the search is running and how many matches there are, and a visible message is far easier to keep accurate than one only some users receive.
- aria-live="assertive" interrupts whatever is being read. Keep it for something the user must act on now, such as a session about to expire. A results count does not qualify.
- AbortController cancels the previous request when the term changes again, so a slow earlier response cannot overwrite a newer one. That is a correctness fix, and it also stops the live region announcing counts for a search the user has moved on from.
A keyboard pass you can do on any screen in a few minutes. It finds more than an automated checker will.
Put the mouse away
Reach the screen and complete its main task using only the keyboard. If you cannot, you have found the most important bug on the page.
Tab through and watch for the focus ring
Every interactive element should be reachable and should show clearly when focused. An element you cannot see focus on is one a keyboard user is lost on.
Check the order matches the layout
Focus should move in the order things appear. A CSS change that reorders elements visually does not reorder the DOM, and that mismatch is disorienting.
Press Enter and Space on every control
Buttons respond to both. A custom control built from a div usually responds to neither, or to Enter only, which is the tell that it is not a button underneath.
Open and close everything
For each dialog, menu and expandable panel: does focus move in, does Escape close it, and does focus come back to where it started?
Check what nothing reaches
Anything reachable by Tab but not operable is worse than nothing. Look for a stray tabIndex on a non-interactive element, and for controls hidden from view but still in the tab order.
Then listen to it
Turn on the screen reader already on your machine and move through the screen. The first time is uncomfortable and worth doing anyway: you hear whether your labels make sense out of context, which no tool can tell you.
Summary
- React renders HTML, so accessibility comes from the elements you choose rather than from the framework
- A native button gives you tab order, Enter and Space, a role and a disabled state; a div with onClick gives you none of them
- Move focus into anything that opens, return it to the opener on close, and never move it because data arrived
- Keep a visible focus indicator, styled with :focus-visible rather than removed
- Announce async changes through a small live region that exists before its text does
- ARIA changes description, not behaviour, so it cannot repair a wrong element
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
The directory has a row action rendered as <div className="row-action" onClick={handleArchive}>Archive</div>, and a designer has added outline: none to the stylesheet because the browser's focus ring clashed with the design.
Fix both. Then list what the original version failed to do that the fixed version does.
Show solution
Replace the div with a button element and keep the class name for styling. A button is in the tab order, is announced as a button, responds to Enter and Space, and supports disabled. All of that arrives with the element, so the fix removes code rather than adding it.
Replace outline: none with a :focus-visible rule that draws a clearly visible indicator. :focus-visible applies when the browser judges the focus should be shown — keyboard use — so it does not appear on a mouse click, which is usually the real objection behind removing it.
What the original failed to do: be reached by Tab at all; report a role, so it was read as plain text; respond to Enter or Space; show a focus state. Any one of those makes the action unavailable to a keyboard user, and together they make it invisible to a screen reader user.
Worth noting for the review: an archive action is destructive, so once it is reachable it should also be labelled precisely. "Archive" alone is ambiguous in a list of forty rows, and an accessible name like "Archive Priya Sharma" removes the ambiguity for someone moving between buttons out of context.
// Before
<div className="row-action" onClick={handleArchive}>
Archive
</div>
// After
<button type="button" className="row-action" onClick={handleArchive}>
Archive<span className="sr-only"> {employee.name}</span>
</button>
/* Before: .row-action:focus { outline: none; }
After: */
.row-action:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}Think about it
Think about it
An employee search updates its results as the user types. A developer wants the first result to receive focus each time the results change, so keyboard users can act on it straight away.
Is that a good idea?
Show solution
No. Focus would be pulled out of the input on every keystroke, so the next character goes somewhere else and the user cannot finish typing. It is the clearest case of a change that sounds helpful and makes the feature unusable.
The underlying rule: move focus in response to a deliberate user action, not in response to data arriving. Opening a dialog is deliberate. A debounced fetch resolving is not.
The need behind the request is real, though. A keyboard user does want to get from the input to the results quickly. Announce the count in a polite live region so they know results exist, and let Tab take them into the list, which is one keystroke and entirely under their control.
If the interaction genuinely needs to be tighter than that, the pattern to reach for is a combobox, where the input keeps focus and Arrow keys move through the options with aria-activedescendant. It is considerably more work than it looks, and worth taking from a well-tested library rather than writing from scratch.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.