Skip to content
elephantoo

Accessibility in React

Lesson 28 of 30 16 min read

Semantic HTML, labels, keyboard support, focus management, ARIA and testing with assistive tech.


Accessibility (often shortened to a11y) means making your app usable by everyone — including people who use screen readers, navigate with only a keyboard, have low vision or colour blindness, or have motor or cognitive disabilities. Around one in six people worldwide lives with a disability, and many more face temporary or situational limits (a broken arm, bright sunlight, a slow connection). Accessible apps are also better for everyone, rank better in search and are a legal requirement in many places.

React renders normal HTML, so web accessibility rules apply directly. This lesson covers the React-specific details and the habits that matter most.

Use semantic HTML#

The single most effective thing you can do is use the right element:

JSX
// ❌ Looks like a button, but keyboard and screen-reader users can't use it
<div className="btn" onClick={handleSave}>
  Save
</div>
JSX
// ✅ Focusable, works with Enter and Space, announced as "Save, button"
<button type="button" className="btn" onClick={handleSave}>
  Save
</button>

Quick guide:

NeedUse
An action (save, open, delete)<button>
Navigation to another page/URL<a href> / router <Link>
Page regions<header>, <nav>, <main>, <aside>, <footer>
Headings, in order<h1> → <h2> → <h3> (don't skip levels for styling)
Lists of things<ul> / <ol> + <li>
Tabular data<table> with <th scope="col">

Screen-reader users often navigate by headings and landmarks, so good structure is like a table of contents for them.

Tip: fragments (<>…</>) let you return several elements without wrapper <div>s that break HTML structure, such as rows inside a <table> or items inside a <ul>.

Accessible forms#

Every input needs a label. In JSX it's htmlFor, not for. For reusable components, generate ids with useId so two instances never clash:

JSX
import { useId } from "react";

export function TextField({ label, hint, error, ...inputProps }) {
  const id = useId();
  const hintId = `${id}-hint`;
  const errorId = `${id}-error`;

  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      {hint && <p id={hintId} className="hint">{hint}</p>}
      <input
        id={id}
        aria-invalid={error ? true : undefined}
        aria-describedby={[hint && hintId, error && errorId].filter(Boolean).join(" ") || undefined}
        {...inputProps}
      />
      {error && (
        <p id={errorId} className="error">
          {error}
        </p>
      )}
    </div>
  );
}

function SignupForm() {
  return (
    <form>
      <TextField label="Email" type="email" name="email" autoComplete="email" required />
      <TextField label="Password" type="password" hint="At least 8 characters" error="Password is too short" />
      <button>Create account</button>
    </form>
  );
}
  • aria-describedby makes screen readers read the hint and error after the label.
  • aria-invalid announces that the field has an error.
  • autoComplete helps everyone, especially people with motor or memory difficulties.
  • Placeholders aren't labels — they disappear when typing and often have poor contrast.
  • Group related radio buttons and checkboxes with <fieldset> and <legend>.

Images and icons#

JSX
function Examples() {
  return (
    <>
      {/* Informative image: describe what matters */}
      <img src="/chart.png" alt="Signups doubled from 1,000 in May to 2,000 in June" />

      {/* Decorative image: empty alt so screen readers skip it */}
      <img src="/divider.svg" alt="" />

      {/* Icon-only button: give it a name */}
      <button aria-label="Close dialog" onClick={onClose}>
        <XIcon aria-hidden="true" />
      </button>
    </>
  );
}

Every <img> needs an alt attribute — descriptive for meaningful images, empty (alt="") for decorative ones.

Keyboard support and focus#

Everything you can do with a mouse should work with a keyboard: Tab moves forwards, Shift+Tab backwards, Enter/Space activate, and Escape closes things. Test your app by putting the mouse away.

Never remove focus outlines without a replacement. Use :focus-visible so keyboard users get a clear ring:

CSS
button:focus-visible,
a:focus-visible,
input:focus-visible {
  outline: 3px solid #6366f1;
  outline-offset: 2px;
}

Managing focus with refs

When content changes, move focus to where the user needs to be. For example, after a client-side route change, focus the new page's heading so screen-reader users hear where they are:

JSX
import { useEffect, useRef } from "react";
import { useLocation } from "react-router";

function PageHeading({ children }) {
  const ref = useRef(null);
  const { pathname } = useLocation();

  useEffect(() => {
    ref.current?.focus();
  }, [pathname]);

  return (
    <h1 ref={ref} tabIndex={-1}>
      {children}
    </h1>
  );
}

tabIndex={-1} lets an element receive focus from code without adding it to the Tab order. Avoid positive tabIndex values — they scramble the natural order.

Accessible dialogs

The native <dialog> element with showModal() gives you focus trapping, Escape-to-close and an inert background for free:

JSX
import { useEffect, useRef } from "react";

export function Modal({ open, onClose, title, children }) {
  const ref = useRef(null);

  useEffect(() => {
    const dialog = ref.current;
    if (open && !dialog.open) dialog.showModal();
    if (!open && dialog.open) dialog.close();
  }, [open]);

  return (
    <dialog ref={ref} onClose={onClose} aria-labelledby="modal-title">
      <h2 id="modal-title">{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </dialog>
  );
}

The browser returns focus to the element that opened the dialog when it closes.

Announcing dynamic changes#

Screen readers don't notice text that appears on its own. Use a live region for status messages:

JSX
function SaveStatus({ status }) {
  return (
    <p role="status" aria-live="polite">
      {status === "saving" && "Saving…"}
      {status === "saved" && "All changes saved"}
    </p>
  );
}
  • role="status" / aria-live="polite" waits for the user to pause.
  • role="alert" interrupts immediately — save it for errors.
  • The live region element should already be on the page before its text changes.

ARIA: use it wisely#

ARIA attributes (role, aria-*) change what assistive technology announces. They add no behaviour — role="button" on a div doesn't make it focusable or clickable by keyboard. The first rule of ARIA: if a native element does the job, use it.

ARIA is genuinely useful for states native HTML can't express:

JSX
function Disclosure({ title, children }) {
  const [open, setOpen] = useState(false);
  const id = useId();
  return (
    <>
      <button aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)}>
        {title}
      </button>
      <div id={id} hidden={!open}>
        {children}
      </div>
    </>
  );
}

Other common ones: aria-current="page" for the active nav link (React Router's NavLink adds it for you), aria-pressed for toggle buttons, and aria-busy while content loads. For complex widgets (comboboxes, menus, tabs, date pickers) use a well-tested library such as React Aria, Radix UI or Headless UI rather than building from scratch.

Colour, motion and text#

  • Contrast: body text needs a contrast ratio of at least 4.5:1 (3:1 for large text) under WCAG AA. DevTools shows the ratio in the colour picker.
  • Don't rely on colour alone: pair red/green with icons or text ("✔ Paid", "✖ Failed").
  • Respect reduced motion:
CSS
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}
  • Use relative units (rem) so text scales with browser settings, and make sure layouts work at 200% zoom.

Testing accessibility#

  1. Keyboard: unplug the mouse and complete the main tasks.
  2. Screen reader: try VoiceOver (macOS/iOS, Cmd+F5), NVDA (Windows, free) or TalkBack (Android).
  3. Automated checks: Lighthouse and the axe DevTools extension find many issues (missing labels, low contrast, missing alt text).
  4. Lint: eslint-plugin-jsx-a11y flags problems as you type; oxlint includes many of the same rules.
  5. Tests: React Testing Library's getByRole queries fail when elements lack accessible roles or names — a free a11y check in every test.

Automated tools catch roughly a third to a half of issues; manual testing finds the rest.

Common mistakes#

  • Clickable <div>s and <span>s instead of buttons and links.
  • Inputs without labels, or using placeholder text as the label.
  • Removing focus outlines with outline: none.
  • Missing or useless alt text (alt="image").
  • Adding ARIA roles that contradict the element (<button role="link">).
  • Modals that don't trap focus or can't be closed with Escape.
  • Skipping heading levels for visual size — style with CSS instead.

What's next#

Your app is fast, tested and accessible. Time to share it with the world: deploying React apps.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Why is <div onClick={save}>Save</div> worse than <button onClick={save}>Save</button>?

  2. 2.How do you connect a <label> to an <input> in JSX?

  3. 3.What is the first rule of ARIA?

Finished reading?

Mark this lesson complete to track your progress.