Accessibility in React
Reviewed & published by Brayan K
By the end of this lesson you'll know how to build React UIs everyone can use: semantic HTML, the htmlFor/id label pattern, ARIA attributes and roles, keyboard navigation and focus management, focus trapping in modals, announcing route changes with live regions, alt text, color contrast, and eslint-plugin-jsx-a11y.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Prefer semantic HTML over generic divs
- Label inputs with the htmlFor/id pattern
- Use aria-* attributes and roles correctly
- Manage keyboard focus with refs and trap focus in modals
- Announce route changes with aria-live regions
- Write alt text, check contrast, and lint with jsx-a11y
1. Semantic HTML first
The single biggest accessibility win is using the right element for the job. A <button>, <a href>, <nav>, or <label> comes with focus, keyboard support, and the correct screen-reader role for free. A <div onClick> gives you none of that — you'd have to rebuild it all by hand, and you'll get it subtly wrong.
2. Labels, aria-* and roles
Every form control needs an accessible name. The cleanest way is a real <label> tied to the input with htmlFor (JSX's name for the HTML for attribute) matching the input's id. When there's no visible text — an icon-only button — give it a name with aria-label, and hide decorative icons with aria-hidden="true". Reach for a role only when no native element conveys the meaning.
Run this to see how a browser computes an element's accessible name, and why an icon-only button with no label is announced as just "button":
// Screen readers announce an element's "accessible name". We SIMULATE
// the priority order the browser uses to compute that name.
function accessibleName(el) {
// Simplified priority: aria-label > associated <label> > text content.
if (el.ariaLabel) return el.ariaLabel;
if (el.labelText) return el.labelText;
if (el.textContent) return el.textContent;
return ""; // empty name -> screen reader users are lost!
}
// An icon-only button with NO accessible name: announced as just "button".
const iconBtn = { textContent: "", ariaLabel: "", labelText: "" };
console.log("icon button name :", JSON.stringify(accessibleName(iconBtn)));
// Same button, now with aria-label:
const fixed = { textContent: "", ariaLabel: "Delete item", labelText: "" };
console.log("fixed button name:", accessibleName(fixed)); // Delete item
// A labelled input wins its name from the <label>:
const input = { textContent: "", ariaLabel: "", labelText: "Email" };
console.log("input name :", accessibleName(input)); // EmailYour turn. An icon button needs an aria-label and its decorative icon should be hidden from screen readers. Fill in the two blanks, then run it and check the output.
// 🎯 YOUR TURN — an icon button needs an ACCESSIBLE NAME and its
// decorative icon should be HIDDEN from screen readers. Fill the blanks.
function buildIconButton({ ariaLabel, iconHidden }) {
// 1) The button must expose a name. Use the ariaLabel argument.
const name = ___; // 👉 ariaLabel
// 2) The icon is decorative, so aria-hidden should be true.
const hidden = ___; // 👉 true
return "<button aria-label='" + name + "'><icon aria-hidden='" + hidden + "'/></button>";
}
console.log(buildIconButton({ ariaLabel: "Close", iconHidden: true }));
// ✅ Expected output:
// <button aria-label='Close'><icon aria-hidden='true'/></button>3. Keyboard navigation & focus management
Many users navigate with the keyboard alone — Tab to move, Enter/Space to activate, Escape to close. Native elements are reachable by default; custom widgets need care. When the UI changes (a form opens, an error appears), you often need to move focus programmatically: grab the node with a useRef and call ref.current.focus(), usually in a useEffect.
🔎 Focus trapping in a modal
Trap focus: while a modal is open, Tab and Shift+Tab should cycle only through the dialog's focusable elements, never the page behind it.
Move focus in: when it opens, focus the first control (or the dialog itself). When it closes, return focus to the element that opened it.
Escape closes it, and the backdrop should be inert. Use role="dialog" with aria-modal="true" and an aria-labelledby pointing at the title.
Tip: libraries like Radix UI / Headless UI implement all of this correctly so you don't have to.
4. Announcing route changes (why SPAs need extra care)
In a traditional site, clicking a link loads a new page and the screen reader announces it. In a single-page app, the router swaps content with JavaScript — no page load happens — so assistive tech is never told anything changed. You must announce it yourself with an aria-live region, and move focus to the new page's heading.
5. Alt text, color contrast & linting
Alt text: every <img> needs an alt attribute. Describe the content or purpose for meaningful images; use an empty alt="" for purely decorative ones so they're skipped. Color contrast: text must stand out from its background — WCAG recommends a contrast ratio of at least 4.5:1 for normal text — or low-vision users can't read it. Linting: add eslint-plugin-jsx-a11y to catch missing alt text, unlabeled inputs, and bad ARIA as you type. It won't catch everything, but it catches the easy mistakes automatically.
Common Errors (and the fix)
- Clickable div: <div onClick> can't be focused or activated by keyboard. Use a <button> (or <a href> for navigation).
- Using for instead of htmlFor: in JSX the label attribute is htmlFor. With plain for the label isn't associated.
- Icon-only button with no name: a screen reader announces "button" with no context. Add aria-label and aria-hidden the icon.
- Modal that doesn't trap focus: Tab leaks to the page behind it. Trap focus, focus the dialog on open, and restore focus on close.
- SPA route change is silent: no live region and no focus move, so screen readers miss the navigation. Add an aria-live announcer.
- Missing alt: <img> with no alt may read out the file name. Add a description, or alt="" if decorative.
Pro Tips
- 💡 Try your app with the keyboard only — unplug the mouse and Tab through everything. Broken spots jump out fast.
- 💡 Native element first, ARIA last. Most accessibility comes free from the right HTML tag.
- 💡 Test with a real screen reader (VoiceOver, NVDA) at least once — automated tools miss context.
- 💡 Lean on accessible component libraries (Radix, React Aria) for complex widgets like menus and dialogs.
📋 Quick Reference
| Goal | How |
|---|---|
| Clickable thing | <button onClick={...}> |
| Label an input | <label htmlFor="id"> + id="id" |
| Name an icon button | aria-label="Close" |
| Hide decorative icon | aria-hidden="true" |
| Move focus | ref.current.focus() |
| Announce a change | <div aria-live="polite"> |
| Catch mistakes | eslint-plugin-jsx-a11y |
Frequently Asked Questions
Q: Why use htmlFor instead of for in JSX?
for is a reserved word in JavaScript, so JSX uses htmlFor. Its value must match the input's id to associate the label.
Q: Do I need ARIA on everything?
No — the opposite. Prefer native semantic elements, which carry the right roles automatically. Add ARIA only when no native element fits.
Q: Why do SPAs need special accessibility handling?
Client-side routing changes content without a full page load, so screen readers aren't notified. You announce the change with a live region and move focus to the new heading.
Q: Will eslint-plugin-jsx-a11y make my app accessible?
It catches many common static mistakes, but not everything. Combine it with keyboard testing and a real screen reader for full coverage.
Mini-Challenge: audit form fields
No blanks this time — just a brief and a starter array. Write auditField(field) that flags fields missing an accessible name. Run it and check your output against the expected lines in the comments.
// 🎯 MINI-CHALLENGE: audit form fields for accessibility
//
// Each field is { id, label, ariaLabel }. A field is ACCESSIBLE if it has
// a non-empty label whose htmlFor matches an id, OR a non-empty ariaLabel.
//
// Write 'auditField(field)' that returns "OK" or "MISSING NAME".
//
// ✅ Expected output:
// email -> OK
// search -> OK
// mystery -> MISSING NAME
const fields = [
{ id: "email", label: "Email", ariaLabel: "" },
{ id: "search", label: "", ariaLabel: "Search the site" },
{ id: "mystery", label: "", ariaLabel: "" },
];
// function auditField(field) {
// your code here
// }
// fields.forEach(f => console.log(f.id + " -> " + auditField(f)));🎉 Lesson Complete
- ✅ Reach for semantic HTML before divs — it carries focus, keyboard, and roles for free
- ✅ Label inputs with htmlFor/id; name icon buttons with aria-label
- ✅ Manage focus with a ref + .focus(), and trap focus in modals
- ✅ Announce SPA route changes with an aria-live region
- ✅ Add alt text, check color contrast, and lint with eslint-plugin-jsx-a11y
Practice quiz
Why should you prefer a button element over a div with an onClick?
- A real button is focusable, keyboard-operable, and announced as a button by screen readers for free
- Divs are slower to render
- Divs cannot have click handlers
- There is no real difference
Answer: A real button is focusable, keyboard-operable, and announced as a button by screen readers for free. Semantic elements like button come with focus, Enter/Space activation, and the correct role built in; a div needs all of that added manually.
How do you correctly associate a label with an input in JSX?
- Add a title attribute to the input
- Use the for attribute on the label
- Use htmlFor on the label matching the input's id
- Wrap the input in a span
Answer: Use htmlFor on the label matching the input's id. In JSX you write htmlFor (not for) on the label, and its value must match the input's id.
How do you move keyboard focus to an element in React?
- Call element.click()
- Get the node via a ref and call ref.current.focus()
- Add tabindex=0 only
- Set autofocus in CSS
Answer: Get the node via a ref and call ref.current.focus(). You attach a ref to the element and call ref.current.focus() (often in an effect) to move keyboard focus to it.
What does an aria-live region do?
- It traps focus inside a modal
- It disables an element
- It sets the page language
- It announces dynamic content changes to screen readers without moving focus
Answer: It announces dynamic content changes to screen readers without moving focus. An aria-live region (e.g. aria-live=polite) tells assistive tech to announce updates to that region as they happen.
Why do single-page apps (SPAs) need extra accessibility care for navigation?
- A client-side route change does not reload the page, so screen readers are not told the page changed
- They load too fast
- They use too much JavaScript
- They cannot use semantic HTML
Answer: A client-side route change does not reload the page, so screen readers are not told the page changed. Because SPAs swap content without a full page load, you must manually announce route changes and manage focus.
What is focus trapping in a modal dialog?
- Hiding the modal from keyboard users
- Keeping Tab focus cycling within the modal until it is closed
- Disabling the keyboard
- Auto-closing the modal after 5 seconds
Answer: Keeping Tab focus cycling within the modal until it is closed. A modal should trap focus so Tab and Shift+Tab cycle only through the dialog's controls, not the page behind it.
What should the alt attribute contain for a meaningful image?
- The image file name
- The word 'image'
- Always an empty string
- A concise description of the image's content or purpose
Answer: A concise description of the image's content or purpose. alt should describe the image's content or function; purely decorative images use an empty alt (alt='') to be skipped.
Why does color contrast matter for accessibility?
- It changes the tab order
- It is required by JSX
- Low contrast text is hard to read for users with low vision or in bright light
- It makes the site load faster
Answer: Low contrast text is hard to read for users with low vision or in bright light. Sufficient contrast between text and background (WCAG suggests at least 4.5:1 for normal text) keeps content readable.
What does eslint-plugin-jsx-a11y do?
- It fixes all accessibility issues automatically at runtime
- It lints JSX for common accessibility problems during development
- It replaces screen readers
- It adds ARIA roles to every element
Answer: It lints JSX for common accessibility problems during development. eslint-plugin-jsx-a11y statically warns about accessibility mistakes (missing alt, bad ARIA, unlabeled inputs) as you code.
When should you add an ARIA role to an element?
- Only when a native semantic element with that meaning is not available
- Only on div elements
- On every element for safety
- Never, ARIA is deprecated
Answer: Only when a native semantic element with that meaning is not available. The first rule of ARIA: prefer a native element. Add a role only when no semantic HTML element conveys the meaning.
Continue this course
- Previous: useFormStatus & Form State
- Next: Animations with Framer Motion — Declarative animations, variants, AnimatePresence and gestures with Framer Motion
- Quick reference: React cheat sheet