Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 34 of 38

Accessibility in React

Build React interfaces everyone can use: semantic HTML, labels with useId, keyboard access, focus management, live regions, dialog and tooling.

20 minBuilding an appUpdated 24 October 2026

By the end of this lessonApply the core accessibility practices to React components: semantic elements, labelled controls, keyboard support, managed focus and announced updates.

About one in five people has a disability that affects how they use the web: low vision, colour blindness, deafness, motor difficulties, cognitive differences. Others have a broken arm, a bright screen or a slow connection. Accessibility (often written a11y) means building so that all of them can use your product. It is also good engineering, part of many laws and contracts, and helps search engines. React does not make this automatically better or worse; it depends on the HTML you produce. This lesson covers the habits that matter most.

1. Start with semantic HTML

Browsers and assistive technologies understand HTML elements: a <button> is focusable, activates with Enter and Space, and is announced as "button". A <nav> is a navigation landmark. A heading structure lets screen reader users jump around. Use the element that matches the meaning, and you get all of that without writing code.

jsx
function App() {
  return (
    <div>
      <header>
        <h1>My shop</h1>
        <nav aria-label="Main">
          <a href="#products">Products</a> | <a href="#cart">Cart</a>
        </nav>
      </header>
      <main>
        <h2 id="products">Products</h2>
        <ul>
          <li>Notebook <button type="button">Add to cart</button></li>
          <li>Pencil <button type="button">Add to cart</button></li>
        </ul>
        <h2 id="cart">Cart</h2>
        <p>Your cart is empty.</p>
      </main>
    </div>
  );
}

export default App;
result
My shop
Products | Cart
Products
Notebook Add to cart
Pencil Add to cart
Cart
Your cart is empty.

Check the structure: one h1, headings that follow in order (h2 under h1, no jumping from h1 to h4 to get a smaller font; style with CSS instead), lists for lists, header, nav and main landmarks, links (a) that go somewhere and buttons that do something. Fragments (<>...</>) help here too, since they let components return several elements without extra wrapper divs that break the structure.

Do not turn divs into buttons

This is the commonest React accessibility bug:

jsx
// Bad: not focusable, no keyboard activation, not announced as a button
<div className="btn" onClick={save}>Save</div>

// Good
<button type="button" onClick={save}>Save</button>

A div with onClick works only for mouse users. To make it work for everyone you would have to add role="button", tabIndex={0}, a key handler for Enter and Space, and focus styles, which is exactly what <button> already is. Style the real button with CSS to look however you like.

2. Label every form control

Every input needs a label that is programmatically linked to it: screen readers read it when the field is focused, and clicking the label focuses the input (a bigger target for everyone). In JSX, for is written htmlFor. You can wrap the input inside the label or link them with matching ids. Hard-coded ids break when a component appears twice, so generate them with useId:

jsx
import { useId } from "react";

function TextField({ label, hint }) {
  const id = useId();                              // unique per component instance
  return (
    <p>
      <label htmlFor={id}>{label}</label><br />
      <input id={id} aria-describedby={hint ? id + "-hint" : undefined} />
      {hint && <small id={id + "-hint"}> {hint}</small>}
    </p>
  );
}

function App() {
  return (
    <form>
      <TextField label="Email" hint="We never share it." />
      <TextField label="Name" />
      <TextField label="City" />
    </form>
  );
}

export default App;
result
Email
 We never share it.
Name
City

useId returns an id that is unique for each call and stable between the server and the client. Three TextFields on one page therefore each get a correct label. aria-describedby links the hint text so it is read after the label. A placeholder is not a label: it disappears when typing and has poor contrast. For errors, use the pattern from the forms lesson: aria-invalid, aria-describedby pointing at the message, and role="alert".

3. Everything must work with the keyboard

Many users never touch a mouse: they use Tab, Shift+Tab, Enter, Space, arrow keys and Escape. Test your app by putting the mouse away:

  • Can you reach every control with Tab, in a logical order?
  • Is the focused element visible? Never write outline: none without a replacement style.
  • Do menus, tabs and dialogs respond to the keys users expect (Escape closes a dialog)?
  • Avoid tabIndex values above 0, which scramble the natural order. Use tabIndex={0} to make a custom element focusable and tabIndex={-1} to allow focusing by code only.

Native elements follow these rules already; custom widgets must reproduce them, following the WAI-ARIA Authoring Practices patterns.

4. Manage focus when the screen changes

In a single-page app, content appears and disappears without the browser reloading, so the browser does not move the user's focus for you. When you open a dialog, reveal a section, or move to a new "page", you must move focus somewhere sensible, using refs (from the refs lesson), and restore it afterwards:

jsx
import { useEffect, useRef, useState } from "react";

function App() {
  const [open, setOpen] = useState(false);
  const headingRef = useRef(null);

  useEffect(() => {
    if (open) headingRef.current.focus();          // move focus into the panel when it opens
  }, [open]);

  return (
    <div>
      <button className="open" onClick={() => setOpen(true)}>Open settings</button>
      {open && (
        <section aria-labelledby="settings-title">
          <h2 id="settings-title" ref={headingRef} tabIndex={-1}>Settings</h2>
          <p>Focus moved to this heading, so a screen reader announces it.</p>
          <button onClick={() => setOpen(false)}>Close</button>
        </section>
      )}
    </div>
  );
}

export default App;
result
Open settings
Settings
Focus moved to this heading, so a screen reader announces it.
Close

The heading has tabIndex={-1}, so code can focus it, though it is not in the Tab order. For a "close" action, return focus to the button that opened the panel (save it in a ref). When the route changes, a common practice is to focus the new page's h1.

The native dialog element

Modal dialogs are hard to get right by hand (trapping focus inside, hiding the background from screen readers, closing with Escape). The HTML <dialog> element does all of that when opened with showModal(). Use a ref to call it:

jsx
import { useRef } from "react";

function App() {
  const dialogRef = useRef(null);
  return (
    <div>
      <button className="open" onClick={() => dialogRef.current.showModal()}>Delete account</button>
      <dialog ref={dialogRef} aria-labelledby="dlg-title">
        <h2 id="dlg-title">Delete your account?</h2>
        <p>This cannot be undone.</p>
        <form method="dialog">
          <button value="cancel">Cancel</button>{" "}
          <button value="delete">Delete</button>
        </form>
      </dialog>
    </div>
  );
}

export default App;
result
Delete account
Delete your account?
This cannot be undone.
Cancel Delete

showModal() opens it with a backdrop, moves focus inside, makes the rest of the page inert and closes it on Escape. A form with method="dialog" closes it when a button is pressed, and the pressed button's value is reported in dialog.returnValue. This is much less code than a hand-made modal, and modern browsers all support it.

5. Announce changes with live regions

Sighted users see a "Saved" message appear. Screen reader users hear nothing unless the update is announced. An element with aria-live="polite" (or role="status") is monitored, and changes to its text are read out without stealing focus. Use role="alert" (assertive) only for urgent errors. The region must exist before its content changes:

jsx
import { useState } from "react";

function App() {
  const [items, setItems] = useState(0);
  return (
    <div>
      <button className="add" onClick={() => setItems(items + 1)}>Add to cart</button>
      <p role="status">{items === 0 ? "" : items + " item" + (items === 1 ? "" : "s") + " in the cart"}</p>
    </div>
  );
}

export default App;
result
Add to cart
2 items in the cart

A screen reader would announce "2 items in the cart" after the second press. Do the same for search result counts, "loading" states, and form submit outcomes.

6. Images, colour and motion

  • Images: every <img> needs an alt attribute. Describe the purpose, not the file ("Asha Rao smiling"). Use alt="" for purely decorative images so that they are skipped. Icons inside a button need an accessible name through text or aria-label.
  • Colour: do not use colour alone to convey meaning (an error that is only red). Text needs enough contrast against its background: at least 4.5:1 for normal text under WCAG. The Image Color Picker and your browser's inspector can help check colours.
  • Motion: respect prefers-reduced-motion in CSS, and avoid auto-playing animation.
  • Zoom and text size: use relative units (rem) so that users can enlarge text, and make sure layouts work at 200% zoom.
  • Language: set lang on the <html> element (the Vite template does: lang="en").

7. Tools and testing

Automatic tools find about a third of the problems, but they are cheap and catch the obvious ones:

  • eslint-plugin-jsx-a11y warns about missing alt text, div click handlers, wrong ARIA and more, as you type.
  • axe DevTools (browser extension), Lighthouse (in Chrome DevTools) and @axe-core/react or vitest-axe in tests check the rendered page.
  • Keyboard test: tab through the whole page.
  • Screen reader test: try VoiceOver (macOS and iOS), NVDA (Windows, free) or TalkBack (Android) on your key flows.
  • Testing Library queries such as getByRole('button', { name: 'Save' }) only succeed when elements have the right roles and names, so well-written tests double as accessibility checks. The testing lesson shows how.

A short checklist

  1. Right element for the job (button, link, heading, list, landmarks).
  2. Every control has a label; errors are linked and announced.
  3. Everything works with the keyboard; focus is always visible.
  4. Focus moves sensibly when content opens, closes or changes page.
  5. Dynamic updates are announced in live regions.
  6. Images have alt; contrast is sufficient; motion is optional.
  7. You tested with a keyboard and a screen reader, not only with tools.

Common mistakes

  • Using div or span with onClick instead of button or a.
  • Removing the focus outline.
  • Placeholders instead of labels.
  • Duplicated hard-coded ids because the component appears more than once.
  • Opening dialogs without moving focus into them or restoring it after.
  • Updating text silently with no live region.
  • Missing alt, or alt="image".
  • Using aria-* attributes where plain HTML would do. The first rule of ARIA is not to use ARIA if a native element can do the job.

Exercise

  1. Tab through a page you built earlier. List the five worst problems you find.
  2. Replace any div click handlers with button elements and restyle them.
  3. Build an accessible tab list (role="tablist", tab, tabpanel) with arrow-key navigation, following the WAI-ARIA Authoring Practices.
  4. Add a live region that announces "3 results found" when a search box filters a list.

Key points

  • Use the right HTML element first: buttons for actions, links for navigation, labels for fields, headings in order. Native elements bring keyboard and screen reader support for free.
  • Link every form control to a label with htmlFor, and generate unique ids with useId so that components stay reusable.
  • Every interactive thing must work with the keyboard, and when content appears or moves, manage focus with refs and announce changes with aria-live.
  • Check your work with a linter (jsx-a11y), a browser tool such as axe, the keyboard, and a screen reader.

Check yourself

1. A clickable <div onClick> is a problem because:
2. Why use useId for form field ids?
3. What does aria-live="polite" do?
4. After opening a modal dialog, where should keyboard focus go?