Accessibility in React: Semantics, Focus, and ARIA
Learn how to build React components that are genuinely usable with a keyboard and a screen reader — semantic HTML, labeled inputs, focus management, focus traps, live regions, and reduced-motion/contrast support — and how to test for it.
React makes it easy to build UI fast, but nothing about JSX makes that UI accessible by default — a <div onClick> button, an unlabeled input, or a modal that doesn't move focus all pass visual review while being unusable for a keyboard-only or screen-reader user. Accessibility in React is mostly about using the right HTML element for the job and then explicitly managing the two things HTML doesn't hand you for free in a dynamic single-page app: focus and announcements.
Semantic HTML and labels come first
Before reaching for any ARIA attribute, reach for the right element. A <button> is focusable, is announced as 'button' by every screen reader, responds to both click and Enter/Space automatically, and participates in forms correctly — a <div onClick={...}> gets none of that for free. The same logic applies across the board: use <nav>, <main>, <header>, <footer>, <article>, and <section> instead of an ocean of <div>s so that a screen reader's landmark navigation (a real feature people use to jump around a page) actually works, and use <ul>/<ol>/<li> for lists instead of styled <div> rows.
Every form input needs a programmatically associated label, not just placeholder text — a placeholder disappears the moment the user types, and many screen readers don't announce it at all. The <label htmlFor='email'> / <input id='email'> pairing (or wrapping the input inside the <label>) is what actually creates that association; aria-label and aria-labelledby exist for the cases where a visible <label> element isn't appropriate, such as an icon-only search button or a table of inputs that share a visual heading.
In React specifically, remember htmlFor instead of for in JSX (for is a reserved word), and remember that generating id values dynamically (for a repeated form row, for example) still needs to keep the label and input's ids matched — a common bug is hardcoding id='email' on every row of a mapped list of email fields, which breaks the label association for every row after the first because ids must be unique per page.
Keyboard navigation, focus management, and focus traps
Every interactive element must be reachable and operable by keyboard alone — Tab and Shift+Tab to move between them, Enter/Space to activate a button, and arrow keys where a native widget (radio groups, native <select>) expects them. Native elements handle this automatically; the moment you build a custom interactive widget out of <div>s or <span>s, you take on the job of wiring up tabIndex, onKeyDown, and the right ARIA role yourself, which is exactly why picking a native element first avoids so much work.
Focus management becomes a real problem the instant your UI does something a static HTML page never has to: open a modal, navigate client-side to a new 'page', or show/hide content without a full page reload. A screen-reader or keyboard user has no visual cue that any of this happened unless focus moves somewhere sensible — into the modal, to the new page's main heading, or to newly revealed content — because their 'cursor' is keyboard focus, not a mouse pointer.
A focus trap keeps Tab and Shift+Tab cycling only within a modal's own focusable elements while it's open, so a keyboard user can't accidentally tab 'through' the dialog into the page content sitting behind it (which should be inert while the modal is open). Focus restoration is the other half: when the modal closes, focus must return to the element that opened it — usually the trigger button — not silently reset to the top of the document, which is the browser's default when the focused element is removed from the DOM.
The full lifecycle for an accessible dialog is: save a reference to document.activeElement before opening, move focus into the dialog (usually onto the dialog container itself or its first focusable child) when it opens, trap Tab/Shift+Tab inside it while open, close it on Escape, and restore focus to the saved reference on close.
ARIA, live regions, and announcing state changes
ARIA (Accessible Rich Internet Applications) attributes describe roles, states, and properties to assistive technology when semantic HTML alone can't express them — role='dialog' and aria-modal='true' on a custom modal, aria-expanded on a disclosure toggle, aria-current='page' on the active nav link. The first rule of ARIA is to use as little of it as possible: a misapplied role or a missing required attribute (like role='dialog' without aria-labelledby pointing at the dialog's title) is often worse than no ARIA at all, because it promises assistive technology a level of support the element doesn't actually deliver.
A live region (aria-live='polite' or aria-live='assertive', or the shorthand role='status'/role='alert') is how you announce content that changes without the user's focus moving there — a toast notification, a 'saved successfully' message, a form validation error, or a search result count updating as someone types. Without a live region, a screen-reader user has no idea any of that happened, because nothing about a DOM update alone triggers an announcement.
This is exactly the mechanism behind accessible loading, error, and empty states: a loading spinner needs role='status' with an accessible label ('Loading lessons…') so its appearance and disappearance are announced; a fetch error needs role='alert' so it's announced immediately (assertive) rather than waiting for the user to be idle (polite); and an empty state ('No results found for "quantum"') needs to actually be read, not just rendered as silent visual text, since a user who submitted a search has no other way to know it returned nothing.
Use aria-live='assertive' sparingly and only for things that truly need to interrupt — form validation errors on submit are a reasonable case; aria-live='polite' is the right default for background updates like autosave status, since assertive announcements cut off whatever the screen reader was already saying.
Reduced motion and contrast
Some users experience real discomfort — dizziness, nausea, migraine triggers — from large-scale motion like parallax scrolling, auto-playing carousels, or big scale/slide transitions. The prefers-reduced-motion media query lets the OS tell your app the user has asked for this, and React components should respect it by swapping animated transitions for instant or minimal ones, exactly the way a CSS-only site would with a media query — this is almost always solved in CSS, not JavaScript, by scoping transition/animation declarations inside @media (prefers-reduced-motion: no-preference) and providing a plain fallback outside it.
Color contrast is a WCAG requirement, not a suggestion: body text needs a contrast ratio of at least 4.5:1 against its background (3:1 for large text), and this is exactly the kind of bug that's invisible in isolated component review but shows up the moment a component is dropped onto a real (especially dark-mode) background. It's also exactly the bug class EngiLipi hit and fixed during its own theme rollout — text colors hardcoded as light literals against a background that later became themeable silently became unreadable once the surrounding tokens flipped, which is why every text/background pairing needs to be checked as a pair, not independently.
Testing for accessibility
Automated tools catch a meaningful subset of issues quickly: axe-core (via the eslint-plugin-jsx-a11y ESLint plugin during development, or the axe DevTools browser extension, or @axe-core/react wired into your dev build) flags missing labels, invalid ARIA usage, insufficient contrast, and missing alt text. These tools typically only catch 30–40% of real accessibility issues, though — they can tell you an attribute is missing but not whether the experience actually makes sense to a screen-reader user.
The two manual checks that catch the rest: tab through the entire page using only the keyboard (no mouse) and confirm you can reach and operate everything in a sensible order with a visible focus indicator at every stop, and turn on a real screen reader (VoiceOver on macOS with Cmd+F5, NVDA on Windows, free) and navigate the page by its headings and landmarks the way an actual user would. Doing this once on every new interactive component — not just at the end of a project — catches problems while they're still cheap to fix.
Accessible modal: focus trap + restoration + live region
role='dialog' plus aria-modal='true' tells assistive technology this is a modal dialog and that content behind it is inert. aria-labelledby points at the visible heading so the dialog announces its own title when it receives focus, instead of needing a separate aria-label. The Tab handler wraps focus from the last focusable element back to the first (and Shift+Tab from the first to the last) — that's the trap. tabIndex={-1} on the container lets it receive programmatic focus (dialogRef.current?.focus()) even though a <div> isn't naturally focusable.
function ConfirmDialog({ isOpen, title, message, onConfirm, onCancel }: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement as HTMLElement;
dialogRef.current?.focus();
} else {
triggerRef.current?.focus();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { onCancel(); return; }
if (e.key !== "Tab") return;
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, [tabindex]:not([tabindex="-1"])'
);
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKey);
return () => document
…Code Examples
Opening the dialog remembers the 'Remove from progress' button as the trigger; confirming closes the dialog, restores focus to that button automatically via the effect, and writes a message into a polite live region so a screen-reader user hears 'Lesson removed from your progress' without focus ever having to move to that paragraph. Expected result: A screen reader announces the status update automatically the moment status changes, with no visual focus change at all.
<p role="status" aria-live="polite">{status}</p>
function LessonActions() {
const [confirmOpen, setConfirmOpen] = useState(false);
const [status, setStatus] = useState("");
const handleDelete = () => {
setConfirmOpen(false);
setStatus("Lesson removed from your progress.");
};
return (
<>
<button onClick={() => setConfirmOpen(true)}>Remove from progress</button>
<ConfirmDialog
isOpen={confirmOpen}
title="Remove this lesson?"
message="This won't delete the lesson, only your progress on it."
onConfirm={handleDelete}
onCancel={() => setConfirmOpen(false)}
/>
<p role="status" aria-live="polite" className="sr-only-unless-present">
{status}
</p>
</>
);
}Focus trap Tab cycle inside an open dialog
Tab from the last focusable element (Cancel) wraps to the first (Confirm), and Shift+Tab from the first wraps to the last — focus never escapes into the page behind it.
Page (inert while dialog open)
┌───────────────────────────────┐
│ ┌─────────────────────────┐ │
│ │ Dialog │ │
│ │ ┌────────┐ │ │
│ │ │ Confirm│◄─Tab────┐ │ │
│ │ └───┬────┘ │ │ │
│ │ │ Tab │ │ │
│ │ ▼ │ │ │
│ │ ┌────────┐ Shift+Tab │ │
│ │ │ Cancel │──wraps──┘ │ │
│ │ └────────┘ │ │
│ └─────────────────────────┘ │
└───────────────────────────────┘Important Rules
Prefer a native semantic element (button, nav, label, ul) over a styled div with ARIA bolted on — native elements come with the right keyboard behavior for free.
Every form input needs a programmatically associated label via htmlFor/id, aria-label, or aria-labelledby — placeholder text is not a label.
A custom modal must set role='dialog' and aria-modal='true', trap Tab/Shift+Tab, close on Escape, and restore focus to the triggering element when it closes.
Use a live region (role='status' for polite updates, role='alert' for urgent ones) to announce content changes that don't move focus, such as toasts, save confirmations, and form errors.
Wrap large-motion transitions in @media (prefers-reduced-motion: no-preference) so users who've opted out of motion get an instant or minimal fallback.
Check contrast as a pair (text color against its actual background), not in isolation — a color that passes on one background can fail on another, especially across light/dark themes.
Where this shows up on EngiLipi
Every confirmation dialog, settings drawer, and 'mark lesson complete' toast on EngiLipi needs this exact pattern — focus trap and restoration on the dialogs, live regions on the toasts and async status messages. It's also one of the most commonly failed checks in real accessibility audits, and one of the cheapest to get right when it's built into the shared component (like a single ConfirmDialog) instead of re-implemented per page.
<div className="btn" onClick={handleSave}>Save</div> <button className="btn" onClick={handleSave}>Save</button>
A div is not in the keyboard tab order by default and isn't announced as an interactive control, so keyboard and screen-reader users can't discover or activate it at all; a button gets focusability, Enter/Space activation, and the right announced role for free.
function Modal({ isOpen, children }: Props) { return isOpen ? <div className="modal">{children}</div> : null; }
A modal that sets role='dialog'/aria-modal='true', moves focus in on open, traps Tab, and restores focus to the trigger on close (see the syntax block above).
A modal with no focus management is visually present but invisible to keyboard and screen-reader users — they keep tabbing through the page behind it, unaware anything opened, and closing it silently resets their focus to the top of the document.
[multiple-choice] Why is <label htmlFor='email'><input id='email' /></label> better than using only a placeholder for an input's label? A. It looks visually identical either way B. The placeholder disappears once the user types and many screen readers don't announce it at all, while a real label is always present and announced C. htmlFor is required by React even for non-form elements D. Placeholders are deprecated in HTML5
B. The placeholder disappears once the user types and many screen readers don't announce it at all, while a real label is always present and announced — A placeholder is not a substitute for a label — it vanishes on input and isn't reliably read by assistive technology, whereas a programmatically associated <label> stays present and is always announced.
[multiple-choice] What should happen to keyboard focus when an accessible modal closes? A. Nothing in particular — focus position doesn't matter B. It should reset to the top of the document, the browser default C. It should return to the element that triggered the modal D. It should move to the page's first link
C. It should return to the element that triggered the modal — Restoring focus to the triggering element preserves the keyboard user's place in the page; letting it silently reset to the document top forces them to re-navigate from scratch.
[multiple-choice] A save-confirmation toast appears without moving keyboard focus to it. How should a screen-reader user be told it appeared? A. They can't be — toasts are inherently inaccessible B. By putting the message text inside an element with role='status' (or aria-live='polite') C. By calling window.alert() instead of rendering a toast D. By increasing the toast's font size
B. By putting the message text inside an element with role='status' (or aria-live='polite') — A live region announces content changes to assistive technology without requiring focus to move there, which is exactly the mechanism a toast needs.
Extend the LessonActions example so that after a lesson is successfully saved to the user's progress, an assertive live region announces 'Error: could not save progress' if the save fails, instead of only the polite success message shown above.
const [error, setError] = useState(""); const handleDelete = async () => { setConfirmOpen(false); try { await saveProgress(); setStatus("Lesson removed from your progress."); } catch { setError("Could not save your progress. Please try again."); } }; // in JSX: <p role="alert">{error}</p>
Add a second live region (or a single region whose aria-live level you don't change, using role='alert' for the error case instead) and set its text in the catch branch of your save call.