React makes interfaces easier to build, but it doesn't make them accessible. A div with an onClick looks like a button and works like one for a mouse user, yet keyboard users can't reach it and screen readers don't announce it. Most accessibility bugs in React apps come from a handful of patterns, and each has a straightforward fix.
Start with native elementsLink to this section
The single most effective rule: use the HTML element that already does the job.
| Need | Use | Not |
|---|---|---|
| An action | <button type="button"> |
<div onClick> |
| Navigation | <a href> or Next.js <Link> |
<span onClick={() => router.push(...)}> |
| Text input | <input> with a <label> |
a contentEditable div |
| Show/hide | <details>/<summary> or a button with aria-expanded |
a clickable heading |
| Modal | <dialog> with showModal() |
a positioned div |
Native elements get focus, keyboard handling and correct roles for free. Every custom replacement has to rebuild all of that, and usually misses something.
Forms that everyone can completeLink to this section
LabelsLink to this section
Every input needs a label that is visible and programmatically connected:
<label htmlFor="email">Email address</label>
<input id="email" name="email" type="email" autoComplete="email" required />Placeholders are not labels: they disappear when typing starts and often fail contrast. Use useId() to generate unique IDs in reusable components:
function Field({ label, error, ...props }: { label: string; error?: string } & React.InputHTMLAttributes<HTMLInputElement>) {
const id = useId();
const errorId = `${id}-error`;
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input id={id} aria-invalid={error ? true : undefined} aria-describedby={error ? errorId : undefined} {...props} />
{error ? (
<p id={errorId} className="field-error">
{error}
</p>
) : null}
</div>
);
}ErrorsLink to this section
When validation fails:
- Mark invalid fields with
aria-invalid="true". - Link the message to the field with
aria-describedby, so screen readers read it when the field gets focus. - Move focus to the first invalid field, or to an error summary at the top of the form.
- Write messages that say how to fix the problem: "Enter an email address like name@example.com", not "Invalid input".
const firstInvalid = useRef<HTMLInputElement>(null);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
const errs = validate(values);
setErrors(errs);
if (Object.keys(errs).length) firstInvalid.current?.focus();
}Don't rely on colour alone to show an error; add an icon or text. Check that error and placeholder text meet WCAG contrast with the contrast checker.
Modals with the native dialog elementLink to this section
Accessible modals used to need a lot of code: trap focus inside, close on Escape, hide the rest of the page from screen readers, return focus afterwards. The <dialog> element opened with showModal() does most of this natively and is supported by all current browsers.
function ConfirmDialog({ open, onClose, onConfirm }: { open: boolean; onClose: () => void; onConfirm: () => void }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
if (open && !d.open) d.showModal();
if (!open && d.open) d.close();
}, [open]);
return (
<dialog ref={ref} aria-labelledby="confirm-title" onClose={onClose}>
<h2 id="confirm-title">Delete this draft?</h2>
<p>This can't be undone.</p>
<div className="actions">
<button type="button" onClick={onClose} autoFocus>
Cancel
</button>
<button type="button" onClick={onConfirm}>
Delete
</button>
</div>
</dialog>
);
}What you get from showModal():
- Content behind the dialog becomes inert: it can't be clicked, tabbed to or read by screen readers.
- Escape closes the dialog and fires the
closeevent. - Focus moves into the dialog, and back to the element that opened it when it closes.
Put autoFocus on the safest action, here Cancel, so a stray Enter press doesn't delete anything. Style the backdrop with dialog::backdrop.
Menus and disclosuresLink to this section
Most "dropdown menus" on websites are really disclosures: a button that shows and hides a list of links. That's simpler and more robust than an ARIA menu, which expects application-style arrow-key navigation.
function NavDropdown({ label, links }: { label: string; links: { href: string; text: string }[] }) {
const [open, setOpen] = useState(false);
const id = useId();
return (
<div onKeyDown={(e) => e.key === "Escape" && setOpen(false)}>
<button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen((o) => !o)}>
{label}
</button>
<ul id={id} hidden={!open}>
{links.map((l) => (
<li key={l.href}>
<a href={l.href}>{l.text}</a>
</li>
))}
</ul>
</div>
);
}aria-expanded tells screen-reader users whether the list is open. Use role="menu" only for genuine application menus, such as an editor's toolbar, and then implement the full keyboard pattern from the WAI-ARIA Authoring Practices.
Focus management after updatesLink to this section
In a single-page app, content changes without a page load, so assistive technology may not notice. Decide where focus should go after each significant change:
- After client-side navigation: move focus to the new page's main heading. Next.js announces route changes, but placing focus on the
h1(withtabIndex={-1}) gives keyboard users a sensible starting point. - After deleting an item: move focus to the next item, or to the list heading if the list is now empty.
- After filtering results: keep focus in the filter and announce the result count.
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => headingRef.current?.focus(), [pathname]);
// <h1 ref={headingRef} tabIndex={-1}>{title}</h1>Live regions for status messagesLink to this section
Some changes shouldn't move focus but should still be heard: "Saved", "3 results", "Copied to clipboard". Render a live region once and change its text:
<p role="status" aria-live="polite" className="visually-hidden">
{status}
</p>The region must exist in the DOM before its text changes, or some screen readers miss the update. Use role="alert" only for urgent errors; it interrupts whatever is being read.
Try it yourself: a live regionLink to this section
Open this in the playground and turn on your screen reader, or watch the text change:
<button type="button" id="save">Save</button>
<p role="status" aria-live="polite" id="status"></p>
<script>
document.getElementById('save').addEventListener('click', () => {
document.getElementById('status').textContent = 'Saved at ' + new Date().toLocaleTimeString();
});
</script>Visible focus and motionLink to this section
- Never remove focus outlines without a replacement. Style
:focus-visiblewith a clear, high-contrast ring. - Respect
prefers-reduced-motionfor animations and parallax. - Use relative units for text and spacing so user font-size settings work; the clamp calculator produces rem-based fluid sizes.
TestingLink to this section
Automated tools catch roughly a third of issues. Combine them with manual checks:
- Keyboard only: Tab through the page. Can you reach and operate everything? Is focus always visible? Can you escape every popup?
- Screen reader: NVDA on Windows or VoiceOver on macOS. Do buttons and fields announce sensible names?
- Zoom to 200%: does the layout still work without horizontal scrolling?
- Automated: axe DevTools or Lighthouse for missing labels, contrast and ARIA misuse;
eslint-plugin-jsx-a11yin the editor; Testing Library queries likegetByRole('button', { name: 'Save' }), which fail when the accessible name is wrong.
An accessibility review routine for every componentLink to this section
Accessibility is easiest to keep when it's part of how each component is finished, not a separate audit at the end of a project. A short routine catches most problems:
- Keyboard only. Unplug the mouse, or just don't touch it. Can you reach every control with Tab, operate it with Enter or Space, and leave it with Tab or Escape? Is the focus always visible?
- Screen reader pass. Use VoiceOver on macOS or NVDA on Windows for a minute. Does each control announce a sensible name, role and state, such as "Menu, button, collapsed"?
- Zoom to 200% and narrow the window to phone width. Does text reflow without horizontal scrolling or overlapping?
- Colour contrast. Check text and focus indicators with the browser's developer tools or an extension. Normal text needs a contrast ratio of at least 4.5:1.
- Reduced motion. Turn on the operating system's reduce-motion setting and check that large animations stop or soften.
- Automated check. Run axe in the browser or in tests to catch missing labels and ARIA mistakes.
Write down the expected keyboard behaviour in the component's documentation or Storybook story, so the next developer knows that Escape closes the menu and focus returns to the trigger, and doesn't remove it by accident.
Content matters tooLink to this section
Components are only part of the picture. Headings in a logical order, link text that makes sense on its own ("Read the caching guide" rather than "Click here"), and alt text on images are the responsibility of whoever writes the content. In WordPress, the block editor helps with heading structure; give editors a short checklist for the rest.
Common problemsLink to this section
- "Clickable" icons with no name. Give icon-only buttons an
aria-label, and hide the decorative SVG witharia-hidden="true". - Custom selects. Rebuilding
<select>accessibly is hard. Style the native one, or use a well-tested library. - Toasts that vanish too fast. Keep status messages visible long enough to read, and don't put actions inside them.
- Focus lost after closing a custom modal. Use
<dialog>, or store the trigger element and focus it on close.
Frequently asked questionsLink to this section
Do I need ARIA everywhere?Link to this section
No. The first rule of ARIA is not to use it when a native element does the job. ARIA changes what assistive technology announces; it doesn't add behaviour.
Is accessibility a legal requirement?Link to this section
In many places, yes, for public-sector sites and increasingly for commercial ones, including the European Accessibility Act for many products and services sold in the EU. Meeting WCAG 2.2 AA is the usual target.
Does accessibility hurt performance or design?Link to this section
No. Native elements are lighter than custom widgets, and clear focus states, readable contrast and good labels make interfaces easier for everyone.
How do I fix an existing app?Link to this section
Start with the templates people use most: navigation, forms and checkout. Fix keyboard access and labels first, then focus management and contrast.
Migrating jQuery widgets? The jQuery to React guide shows an accessible filter. For an accessibility review of your site, get in touch.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.