cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

Accessible React components

Build React components everyone can use: real buttons, labels and useId, focus moved with refs, live regions, and form errors screen readers can read.

By the end, you can
  • 01
    Pick the element that does the jobUse buttons, links, labels, and headings in JSX, and say what a div with onClick is missing for keyboard and screen reader users.
  • 02
    Name controls and connect themGive icon buttons a name with aria-label, join labels and errors to fields with useId, aria-describedby, and aria-invalid.
  • 03
    Manage focus and announce changesMove the focus with refs after a delete or a dialog, announce updates with a live region, and check your work with the keyboard, a linter, and axe.

A button only a mouse can press

Many people use the web without a mouse. Some can't hold one steady, so they use the keyboard. Blind people use a screen reader, a program that reads the page aloud and is driven from the keyboard. Others speak commands, like "click Add cake". A component is accessible when all of them can use it. Here is a cart with two ways to add an item. Click both with your mouse: both add to the count.

A div and a button that look the samePop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [count, setCount] = useState(0);   return (    <>      <p>Items in cart: {count}</p>      <div className="fake-button" onClick={() => setCount(count + 1)}>        Add tea      </div>      <button onClick={() => setCount(count + 1)}>Add cake</button>    </>  );}
ResultLoading…

Line 8 shows the count. Lines 9 to 11 are a <div> with an onClick; the styles.css tab gives it the class fake-button, so it looks like a button. Line 12 is a real <button> with the same click handler.

Now use only the keyboard. Click the words Items in cart in the result, so the keys go to it, and press Tab. The focus ring, the outline that shows which element the keys go to, lands on Add cake. Tab skipped Add tea. Press Enter or Space: the count goes up. There is no way to press Add tea from the keyboard, and a screen reader reads it as plain text, not as something you can press. The Keyboard & focus lesson covers how the browser moves the focus.

A Tab key card on the left. An arrow curves from it over a crossed-out card, Add tea (div), and lands on a card, Add cake (button), inside a page window. Under the div: Tab goes past. Under the button: Tab stops here.Tabthe Tab keypageAdd tea (div)Add cake (button)Tab goes pastTab stops here
Tab moves the focus from one control to the next. It goes past the <div>, which the browser treats as text, and stops on the <button>, where Enter and Space press it.

Nothing in React is broken here. React puts the elements you write on the page, and the browser decides what each one can do. A <div> is a box for layout; a <button> is a control. Most of accessibility in React is choosing the right elements and attributes in JSX. The rest is what JSX can't describe: where the focus goes when the page changes, and telling screen reader users about changes they can't see.

Real-life analogyA real door, or one painted on the wall

A real shop door opens however you arrive: you push it, pull its handle, or the sensor opens it for your wheelchair. Now picture a door painted on the wall, with a hidden latch. It looks just the same. But only people who know to feel for the latch can get in, and everyone else walks past it.

In real life: A real shop door with a handle
In React: A <button>
In real life: A door painted on the wall, with a hidden latch
In React: A <div onClick> styled to look like a button
In real life: People push it, pull the handle, or wait for the sensor
In React: People click, press Tab then Enter, or use a screen reader or their voice
In real life: Only people who find the latch with their hand get in
In React: Only a mouse click presses the div
The main idea

An accessible component works for everyone: with a mouse, with only a keyboard, with a screen reader, or by voice. In React it comes from four things: the elements you choose in JSX, the names you give controls, the focus you move when the page changes, and the changes you announce.

Use the element that already does the job

Each HTML element comes with behavior the browser builds in. A <button> is a stop for Tab, Enter and Space press it, and it has a role, the kind of thing a screen reader says it is: "button". Using elements for what they mean is called semantic HTML. Here is what it takes to give a <div> the same behavior, beside the button that has it already:

A div rebuilt as a button, and a buttonPop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [count, setCount] = useState(0);  const addTea = () => setCount(count + 1);   return (    <>      <p>Items in cart: {count}</p>      <div        className="fake-button"        role="button"        tabIndex={0}        onClick={addTea}        onKeyDown={(event) => {          if (event.key === "Enter" || event.key === " ") {            event.preventDefault();            addTea();          }        }}      >        Add tea      </div>      <button onClick={addTea}>Add tea</button>    </>  );}
ResultLoading…

Line 5 is the handler both controls share. The div needs lines 10 to 23. Line 12, role="button", tells screen readers to call it a button. Line 13, tabIndex={0}, puts it in the Tab order. Lines 15 to 20 press it when the key is Enter or Space, and line 17 stops Space from scrolling the page, as it does by default. Line 24 is the button: one line. Click in the result and press Tab: both are stops now, and Enter and Space press both.

Even after fourteen lines the div isn't a button. disabled does nothing to it, it can't submit a form, and every copy needs all of this again. Try each step in the playground: it starts with a bare div and adds one thing at a time.

Playground: from a div to a button
The control at this stepJSX
<div className="fake-button" onClick={addTea}>
  Add tea
</div>
The pageItems in cart: 0
Add tea

Not pressed yet. Click Before, then press Tab.

  • Yes A mouse click presses it
  • No Tab reaches it
  • No Enter and Space press it
  • No A screen reader says "button"
  • No disabled turns it off
Try it yourself
1 of 5: div + onClick

A plain div with a click handler. A mouse can press it, but Tab goes straight past it, and a screen reader reads it as text, not as a button.

Each step renders with React on this page, written the way the code shows. Click the control, then press Tab to move to it and press Enter or Space. The log lists the last five presses and how each arrived. Reset goes back to the plain div.

Click Before in the playground's page, then press Tab. On the first three steps Tab passes the div, or Enter does nothing. On + onKeyDown and <button>, the log shows Enter or Space. The checklist's last line stays No until the real button.

How to read the table: each row is a job, the element made for it, and what the browser gives that element for free.

Elements that already do the job
What you needUseWhat the browser gives you
An action on this page, like Add to cart<button>A Tab stop, Enter and Space, the word "button" for screen readers, disabled, and submitting its form.
Going to another page or place<a href="…">A Tab stop, Enter, the word "link", and opening it in a new tab.
A yes or no choice<input type="checkbox"> in a <label>Space toggles it, clicking the label toggles it, and screen readers say whether it's checked.
A title for a part of the page<h2> to <h6>Screen reader users can jump from heading to heading to find the part they want.
The site menu and the main content<nav> and <main>Landmarks: shortcuts that take a screen reader straight to the menu or the content.
A group of similar things<ul> with <li>Screen readers say it's a list and how many items it has.

A link goes somewhere and a button does something. <span onClick={() => navigate("/offers")}> breaks both rules: write <a href="/offers">. And because a component can return a Fragment, <>…</>, you never need a wrapper <div> that would break a list or a table: a component can return two <li> elements or two <td> cells as they are.

Accessibility attributes go in JSX much as in HTML. How to read the table: the HTML you may know, how to write it in JSX, and why.

Accessibility attributes in JSX
In HTMLIn JSXWhy
for="email"htmlFor="email"for is a JavaScript word, so JSX uses the DOM property's name.
tabindex="0"tabIndex={0}Like most attributes, it's camelCase in JSX.
aria-label="Close"aria-label="Close"aria-* attributes keep their dashes and lowercase letters.
aria-expanded="true"aria-expanded={open}React writes true or false as the text "true" or "false".
role="status"role="status"The same as HTML.

A name for every control, and ids that don't clash

A screen reader says two things about each control: its role and its name, as in "Remove Cake, button". A button's name is its text. A field's name is its label. A button that shows only a symbol gets the symbol as its name:

Two × buttons, one with a namePop out in the code editor (opens in a new tab)JSX
export default function App() {  return (    <ul>      <li>        Tea <button>×</button>      </li>      <li>        Cake <button aria-label="Remove Cake">×</button>      </li>    </ul>  );}
ResultLoading…

The two buttons look the same, and that's the problem: the difference only shows to a screen reader. Line 5's button is named by its text, the symbol ×, which screen readers read as "times" or "multiplication sign". Line 8 adds aria-label="Remove Cake", which replaces the text as the name, so the button is read as "Remove Cake, button". Give every control that has no words of its own an aria-label. When a control does show words, keep them in its name, so people who say "click Remove" can still reach it.

Fields get their names from labels. The Forms lesson joined a <label> to its field with htmlFor and an id. An id has to be unique on the page, and a hard-coded id isn't, once you use the component twice. Click Cake quantity in this result:

A field with a hard-coded id, used twice (a bug on purpose)Pop out in the code editor (opens in a new tab)JSX
function QuantityField({ label }) {  return (    <p>      <label htmlFor="quantity">{label}</label>{" "}      <input id="quantity" type="number" defaultValue={1} min={1} />    </p>  );} export default function App() {  return (    <>      <QuantityField label="Tea quantity" />      <QuantityField label="Cake quantity" />    </>  );}
ResultLoading…

The cursor jumps into Tea's box. Lines 4 and 5 write id="quantity" in the component, so lines 13 and 14 put two elements with that id on the page. The browser joins each label to the first element with the id: both labels point at Tea's box, and Cake's box has no label at all. A screen reader user moving to it hears only what kind of box it is, with no idea what it's for.

Left, a hard-coded id: two label cards, Tea quantity and Cake quantity, both with arrows to the first of two boxes, each marked id="quantity". The second box has no arrow. Right, useId: each label card has an arrow to its own box, one marked _r_0_ and one marked _r_1_.A hard-coded idTea quantityid="quantity"Cake quantityid="quantity"no label points hereuseId()Tea quantityid="_r_0_"Cake quantityid="_r_1_"each label finds its own box
With id="quantity" in the component, both copies share one id, and both labels point at the first box. With useId, each copy gets its own id, so each label points at its own box.

React's useId Hook fixes it. It gives each copy of a component its own id, and the same id every time that copy renders:

The same field with useIdPop out in the code editor (opens in a new tab)JSX
import { useId } from "react"; function QuantityField({ label }) {  const id = useId();  return (    <p>      <label htmlFor={id}>{label}</label>{" "}      <input id={id} type="number" defaultValue={1} min={1} />    </p>  );} export default function App() {  return (    <>      <QuantityField label="Tea quantity" />      <QuantityField label="Cake quantity" />    </>  );}
ResultLoading…

Line 4 asks React for this copy's id. Line 7 joins the label to it, and line 8 gives the box the same id. Click Cake quantity now: the cursor goes into the second box. The ids look like _r_0_ and _r_1_; you never write them yourself.

Why not count yourself, with a variable outside the component? It works in one browser, but not on a site whose HTML a server makes first:

Ids from a counter: fine alone, wrong with a serverJSX
let nextId = 0;

function QuantityField({ label }) {
  const id = `quantity-${++nextId}`;
  return (
    <p>
      <label htmlFor={id}>{label}</label>{" "}
      <input id={id} type="number" defaultValue={1} min={1} />
    </p>
  );
}

Line 1 keeps a counter, and line 4 takes the next number on every render. Frameworks like Next.js first run your components on a server to make the page's HTML, then React runs them again in the browser and connects to that HTML. This is called hydration, and it expects both runs to produce the same page. But the server keeps the module loaded between visitors, so the second visitor's HTML says quantity-3 and quantity-4, while their browser starts counting at 1. React warns that the server's HTML doesn't match. Even in the browser alone, the counter counts renders, not fields: in development, StrictMode calls each component twice, so this example's ids come out as quantity-2 and quantity-4. useId builds the id from the component's place in the tree, which is the same on the server and in the browser. The Server rendering and hydration lesson covers hydration.

Move the focus when the page changes

The focus is where the keyboard's keys go. When an action removes the focused element, or opens something new, the browser doesn't know where the user wants to be next. You do. This cart's rows each have a Remove button, named with aria-label. The FocusReport.jsx tab adds a line under the cart that says where the focus is; you don't need to read it.

Remove a row without moving the focus (a bug on purpose)Pop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import FocusReport from "./FocusReport.jsx"; const startItems = [  { id: 1, name: "Tea" },  { id: 2, name: "Cake" },  { id: 3, name: "Lemonade" },]; export default function App() {  const [items, setItems] = useState(startItems);   function remove(index) {    setItems(items.filter((_, i) => i !== index));  }   return (    <>      <h2>Your cart ({items.length})</h2>      <ul>        {items.map((item, index) => (          <li key={item.id}>            {item.name}{" "}            <button onClick={() => remove(index)} aria-label={`Remove ${item.name}`}>              Remove            </button>          </li>        ))}      </ul>      <FocusReport />    </>  );}
ResultLoading…

Click the heading Your cart in the result, press Tab twice to reach Cake's button, and press Enter. The row goes, and the last line says Focus: the page itself (<body>). Line 14 only removes the item from the list. The focused button was in that row, line 24, and when React removes it, the browser can't keep the focus on an element that's gone, so it puts the focus on <body>. Press Tab: you're back at the first button, and a screen reader user has lost their place.

Fix it by deciding where the focus goes: to the next row's button, to the one before when you removed the last row, and to the heading when the cart is empty. You need the DOM nodes, so this is a job for refs:

Remove a row, and move the focus with refsPop out in the code editor (opens in a new tab)JSX
import { useRef, useState } from "react";import FocusReport from "./FocusReport.jsx"; const startItems = [  { id: 1, name: "Tea" },  { id: 2, name: "Cake" },  { id: 3, name: "Lemonade" },]; export default function App() {  const [items, setItems] = useState(startItems);  const heading = useRef(null);  const buttons = useRef(new Map());   function remove(index) {    const next = items[index + 1] ?? items[index - 1];    setItems(items.filter((_, i) => i !== index));    if (next) buttons.current.get(next.id).focus();    else heading.current.focus();  }   return (    <>      <h2 ref={heading} tabIndex={-1}>        Your cart ({items.length})      </h2>      <ul>        {items.map((item, index) => (          <li key={item.id}>            {item.name}{" "}            <button              ref={(node) => {                buttons.current.set(item.id, node);                return () => buttons.current.delete(item.id);              }}              onClick={() => remove(index)}              aria-label={`Remove ${item.name}`}            >              Remove            </button>          </li>        ))}      </ul>      <FocusReport />    </>  );}
ResultLoading…
  • Line 12 is a ref for the heading, and line 24 lets code focus it: tabIndex={-1} makes an element focusable by focus() without adding a Tab stop.
  • Line 13 keeps a Map of buttons by item id. Lines 32 to 35 are a ref callback: React calls it with each button's node, which it adds to the Map, and calls the function it returns when the button goes, which deletes it.
  • Line 16 picks the row after the removed one, or the row before when there is none after.
  • Line 18 focuses that row's button, and line 19 focuses the heading when no row is left. That button is already on the page, and React keeps it because its key doesn't change, so focus() can run right away, before React re-renders.

Try the same keys again. After Remove Cake, the last line says Focus: <button> Remove Lemonade. Remove the rest, and it ends on the heading.

Two cart windows after Remove Cake, each with two buttons left: Remove Tea and Remove Lemonade. Left, no focus code: the focus is on body, the page itself. Right, with a ref: an arrow labelled focus points at Remove Lemonade.After you press Remove Cakeno refRemove TeaRemove LemonadeFocus: the page itself, <body>a refRemove TeaRemove LemonadefocusFocus: Remove Lemonade
Both carts after you press Remove Cake. Without focus code, the focused button is gone and the focus falls to <body>. With a ref, the code moves it to Remove Lemonade, the next row's button.

Opening something works the other way: the focus should move into it, and come back when it closes. A dialog, a box that sits over the page until you answer it, needs three things: the focus moves into it when it opens, Tab stays inside it while it's open, and the focus returns to the button that opened it when it closes. The browser's <dialog> element does all three when you open it with showModal():

A confirm dialog that handles the focusPop out in the code editor (opens in a new tab)JSX
import { useId, useRef, useState } from "react";import FocusReport from "./FocusReport.jsx"; export default function App() {  const [items, setItems] = useState(["Tea", "Cake"]);  const dialog = useRef(null);  const titleId = useId();   function emptyCart() {    setItems([]);    dialog.current.close();  }   return (    <>      <p>Items in cart: {items.length}</p>      <button onClick={() => dialog.current.showModal()}>Empty cart…</button>      <dialog ref={dialog} aria-labelledby={titleId}>        <h2 id={titleId}>Empty your cart?</h2>        <button onClick={() => dialog.current.close()}>Keep my items</button>        <button onClick={emptyCart}>Empty it</button>      </dialog>      <FocusReport />    </>  );}
ResultLoading…

Line 17 calls showModal() on the dialog through the ref from line 6. Line 18 names the dialog with aria-labelledby, pointing at the heading on line 19 through an id from useId on line 7, so a screen reader announces "Empty your cart?, dialog". Press Empty cart… from the keyboard: the focus moves to Keep my items, and the rest of the page is inert: Tab and clicks can't reach it. Press Escape or either button, and the focus returns to Empty cart…. The Portals lesson covers modals you build yourself.

For a panel that isn't a dialog, like a filter list that opens under a button, focus its first control in the same handler that opens it, after flushSync has put it on the page (flushSync, from Manipulating the DOM with refs, makes React update the page before the next line runs). And move the focus only after an action the user took; a focus that jumps while someone reads or types is as confusing as one that's lost.

Announce changes with a live region

Some changes happen away from the focus: "Added to cart", "12 results", "Saved". A sighted user sees them; a screen reader user hears nothing, because a screen reader reads what the focus is on. A live region is an element whose text changes the screen reader reads aloud, wherever the focus is.

A status line that says what each click didPop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [items, setItems] = useState([]);  const [message, setMessage] = useState("");   function add(name) {    const count = items.length + 1;    setItems([...items, name]);    setMessage(`Added ${name}. ${count} ${count === 1 ? "item" : "items"} in your cart.`);  }   return (    <>      <button onClick={() => add("Tea")}>Add tea</button>      <button onClick={() => add("Cake")}>Add cake</button>      <p role="status">{message}</p>    </>  );}
ResultLoading…

Line 5 keeps the message in state. Line 10 writes a new message on each click, with the new count. Line 17 is the live region: role="status" makes the paragraph one. It's on the page from the start, empty, and each click only changes its text. Click Add tea: the line says Added Tea. 1 item in your cart. With a screen reader on (VoiceOver: Command+F5 on a Mac; NVDA is free on Windows), you'd hear it read while the focus stays on the button.

Three steps. 1, the page loads with an empty status paragraph, p role="status". 2, after Add tea, the same paragraph says Added Tea. 3, a voice window reads: Added Tea. 1 item in your cart.1. The page loadspage<p role="status">the region, empty2. Add teapage<p role="status">Added Tea. …only its text changes3. Read aloudvoiceAdded Tea. 1 itemin your cart.no focus moves
The status paragraph is on the page from the start, empty. A click only changes its text, and the screen reader reads the new text: Added Tea. 1 item in your cart.

The order matters. A screen reader watches the live regions already on the page for changes. If the region arrives together with its text, as with {message && <p role="status">{message}</p>}, many screen readers miss that message. Try the three ways of writing line 17:

Playground: what would a screen reader say?
The cart, with this status lineJSX
import { useState } from "react"; export default function App() {  const [items, setItems] = useState([]);  const [message, setMessage] = useState("");   function add(name) {    const count = items.length + 1;    setItems([...items, name]);    setMessage(`Added ${name}. ${count} ${count === 1 ? "item" : "items"} in your cart.`);  }   return (    <>      <button onClick={() => add("Tea")}>Add tea</button>      <button onClick={() => add("Cake")}>Add cake</button>      <p role="status">{message}</p>    </>  );}
The pageline 17

  1. Press Add tea, then Add cake.
Try it yourself
Region always there

The status paragraph is on the page from the start, empty. Each click only changes its text, so each message is read out.

The cart runs with React on this page, written the way the code shows, and a MutationObserver records each change React makes to it. The log follows the rule screen readers share: text that changes inside a live region already on the page is read; a region added with its text may be missed; text outside a live region isn't read. It's a model of that rule, not a screen reader. Reset starts over.

With the region always there, both clicks are read. Added with the message, the first click's message may be missed, and the second is read, because by then the region exists. With no live region, nothing is read at all.

How to read the table: each kind of live region, how it speaks, and what it's for.

Kinds of live region
WriteHow it speaksUse it for
role="status"Waits until the screen reader finishes what it's saying.Results counts, "Added to cart", "Saved".
role="alert"Speaks at once, and most screen readers stop what they were saying.Errors that stop the user, like "Payment failed".
aria-live="polite"Waits, like status.Any element you want to work like a status line.

Keep messages short, and announce only what the user needs. A status line can also be hidden from sight with a CSS class that keeps it readable, often called visually-hidden; don't use display: none, which hides it from screen readers too.

Form errors a screen reader can find

A red border and a message beside a field are clear if you can see them. To a screen reader they're just more text somewhere on the page. An accessible error is joined to its field, and the focus takes the user to it. This checkout form puts together everything so far:

A checkout form with accessible errorsPop out in the code editor (opens in a new tab)JSX
import { useId, useState } from "react";import { flushSync } from "react-dom"; function Field({ label, name, type = "text", error }) {  const id = useId();  const errorId = `${id}-error`;  return (    <p>      <label htmlFor={id}>{label}</label>{" "}      <input        id={id}        name={name}        type={type}        aria-invalid={error ? true : undefined}        aria-describedby={error ? errorId : undefined}      />      {error && (        <span id={errorId} className="error">          {error}        </span>      )}    </p>  );} function check({ name, email }) {  const errors = {};  if (!name.trim()) errors.name = "Enter your name.";  if (!email.includes("@")) errors.email = "Enter an email address with an @.";  return errors;} export default function App() {  const [errors, setErrors] = useState({});  const [sent, setSent] = useState(false);   function handleSubmit(event) {    event.preventDefault();    const form = event.currentTarget;    const found = check(Object.fromEntries(new FormData(form)));    flushSync(() => setErrors(found));    const first = Object.keys(found)[0];    if (first) form.elements[first].focus();    setSent(!first);  }   return (    <form onSubmit={handleSubmit} noValidate>      <Field label="Name" name="name" error={errors.name} />      <Field label="Email" name="email" type="email" error={errors.email} />      <button>Place order</button>      <p role="status">{sent ? "Thanks! Your order is on its way." : ""}</p>    </form>  );}
ResultLoading…

Press Place order with both boxes empty. Both errors appear, the boxes turn red, and the cursor lands in Name. Type a name and press it again: the cursor moves to Email. Fill in both, and the status line thanks you.

  • Line 5 gets an id for each Field, and line 6 makes the error's id from it. Line 9 joins the label, as in the names section.
  • While there's an error, line 14 sets aria-invalid, so a screen reader says "invalid", and line 15 sets aria-describedby to the error's id, so it reads the message after the label. Line 18 gives the message that id. With no error, both are undefined, and React leaves them out.
  • Line 48's noValidate turns off the browser's own error bubbles, so these messages are the only ones. The Form validation lesson covers the browser's checks.
  • In handleSubmit, line 40 reads the boxes with FormData and checks them with check. Line 41 sets the errors inside flushSync, which makes React put them on the page before the next line runs. Line 43 then focuses the first wrong field, found by its name in form.elements.

Why flushSync? A screen reader reads a field when the focus arrives. Without it, line 43 would focus the field before React added the error and aria-describedby, and the field would be read without them. With it, the user hears something like "Name, invalid entry, Enter your name." The [aria-invalid="true"] rule in styles.css draws the red border from the same attribute, so the color and what's read can't disagree.

Test with the keyboard, a linter, and axe

Each kind of check finds problems the others miss. The quickest is your own keyboard:

  • Put the mouse away. Press Tab and Shift+Tab through the page: every control should get a stop, in reading order.
  • Watch the focus ring at every stop. Never remove it with outline: none unless you draw another one in its place, for example with the :focus-visible selector.
  • Press Enter and Space on buttons, Escape in dialogs, and do every action you would do with the mouse.
  • After each action, check where the focus went.

A linter reads your code as you type and reports mistakes. eslint-plugin-jsx-a11y is ESLint's accessibility plugin for JSX. Next.js's ESLint config turns on a few of its rules; this turns on its recommended set:

Turning on jsx-a11y's recommended rulesJavaScript
// eslint.config.js
import jsxA11y from "eslint-plugin-jsx-a11y";

export default [
  jsxA11y.flatConfigs.recommended,
];

Line 2 imports the plugin, and line 5 adds its recommended rules to an ESLint flat config. Given the overview's div, the plugin reports two problems:

The overview's div, as the linter sees itJSX
<div className="fake-button" onClick={() => setCount(count + 1)}>
  Add tea
</div>
  • jsx-a11y/click-events-have-key-events: "Visible, non-interactive elements with click handlers must have at least one keyboard listener."
  • jsx-a11y/no-static-element-interactions: "Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element."

The first says a click handler needs a keyboard handler beside it. The second says the div isn't a control, so it shouldn't take one at all. Both point to the same fix, a <button>. The plugin doesn't catch everything: its recommended rules say nothing about the unnamed × button from the names section, because a button with any text, even a symbol, counts as named.

axe, from the company Deque, checks the page as it's rendered: buttons and fields with no name, missing labels, colors too faint to read, and wrong ARIA. Run it as the axe DevTools browser extension; Chrome's Lighthouse report uses it too. In tests, axe-core runs inside jest-axe, vitest-axe, or @axe-core/playwright. The older @axe-core/react package doesn't support React 18 or later (its README says so, checked in October 2026). axe can't see your click handlers or where the focus goes, so it doesn't replace the keyboard. The Testing React components lesson finds elements by role and name, the way a screen reader does.

How to read the table: each check, what it finds, and what it can't find.

What each check finds
CheckWhat it findsWhat it misses
Using only the keyboardControls Tab skips, keys that do nothing, lost focus, no visible focus ring.Whether names and messages make sense when read aloud.
eslint-plugin-jsx-a11yA div with only onClick, a label with no field, an image with no alt, while you type.Anything that depends on what the page holds when it runs.
axe (browser extension or in tests)Buttons and fields with no name, missing labels, low color contrast, on the page as rendered.Click handlers, focus after an action, and whether a message is announced.
A screen reader (VoiceOver, NVDA, TalkBack)What a blind user hears: names, roles, errors, announcements.Nothing on its own; it takes some practice to use.

An accessible cart

Here is a cart that uses every part of the lesson: real buttons, names for the symbol buttons, the focus moved after a remove, and a status line. Use it with only the keyboard.

A cart that works from the keyboard and a screen readerPop out in the code editor (opens in a new tab)JSX
import { useRef, useState } from "react";import FocusReport from "./FocusReport.jsx"; const startItems = [  { id: 1, name: "Tea", quantity: 1 },  { id: 2, name: "Cake", quantity: 2 },  { id: 3, name: "Lemonade", quantity: 1 },]; export default function App() {  const [items, setItems] = useState(startItems);  const [message, setMessage] = useState("");  const heading = useRef(null);  const removeButtons = useRef(new Map());   function changeQuantity(item, change) {    const quantity = Math.max(1, item.quantity + change);    setItems(items.map((other) => (other.id === item.id ? { ...other, quantity } : other)));    setMessage(`${item.name}: ${quantity}`);  }   function remove(index) {    const next = items[index + 1] ?? items[index - 1];    setItems(items.filter((_, i) => i !== index));    setMessage(`Removed ${items[index].name}.`);    if (next) removeButtons.current.get(next.id).focus();    else heading.current.focus();  }   return (    <section aria-label="Shopping cart">      <h2 ref={heading} tabIndex={-1}>        Your cart ({items.length})      </h2>      <ul>        {items.map((item, index) => (          <li key={item.id}>            {item.name}{" "}            <button onClick={() => changeQuantity(item, -1)} aria-label={`One less ${item.name}`}>              −            </button>{" "}            {item.quantity}{" "}            <button onClick={() => changeQuantity(item, 1)} aria-label={`One more ${item.name}`}>              +            </button>            <button              ref={(node) => {                removeButtons.current.set(item.id, node);                return () => removeButtons.current.delete(item.id);              }}              onClick={() => remove(index)}              aria-label={`Remove ${item.name}`}            >              ×            </button>          </li>        ))}      </ul>      <p role="status">{message}</p>      <FocusReport />    </section>  );}
ResultLoading…
  • Lines 39 to 45 are the − and + buttons. They show symbols, so aria-label names them: One less Tea, One more Tea. Line 19 says the new quantity in the status line on line 59.
  • Lines 46 to 55 are the × button, named Remove Tea, with the ref callback from the focus section. remove, lines 22 to 28, picks the next row, removes the item, says Removed Tea., and moves the focus.
  • Line 31's aria-label names the whole <section>, so a screen reader can list it as a landmark, Shopping cart.

Click the heading, press Tab twice to reach One more Tea, and press Enter: the quantity becomes 2 and the status line says Tea: 2. Tab to Tea's × and press Enter: the status line says Removed Tea. and the focus line says <button> Remove Cake.

Which fix does it need?
  • <div onClick={openMenu}>Menu</div>
  • <span onClick={() => navigate("/offers")}>Offers</span>
  • <button onClick={close}>×</button>
  • <input placeholder="Email" />
  • After Delete, the focus is on <body>
  • Filters open, but the focus stays on the page behind
  • {saved && <p>Saved!</p>}
  • <p>{results.length} results</p>
Try it yourself
0 of 8 correct

Sort each card by what a keyboard or screen reader user needs: a real element, a name, the focus moved, or a change announced.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "role="button" turns a div into a button." No. It only changes what screen readers say. Tab, Enter, Space, and disabled still need code, and a <button> has them already.
  • "A placeholder is a label." No. It disappears as soon as you type, so the field loses its name on screen, and some screen readers don't read it. Join a <label>.
  • "React makes my components accessible." No. React puts the elements you write on the page. A div with onClick is as hard to use in React as in plain HTML.
  • "aria-label on everything helps." No. It replaces an element's own text as its name. Use it for controls with no words, like ×; on a button that already says "Remove", keep that word in the name.
  • "tabIndex={1} fixes the Tab order." No. A positive tabIndex jumps ahead of everything else on the page. Put the elements in the right order in JSX, and use only 0 and -1.
  • "The focus ring looks untidy, so remove it." No. Without it, keyboard users can't tell where they are. Restyle it with :focus-visible instead.
  • "If axe finds nothing, the page is accessible." No. axe can't press keys or follow the focus. Test with the keyboard, and with a screen reader when you can.

Practice exercises

Each exercise shows its code's result. Click in it and use the keyboard: Tab, Shift+Tab, Enter, and Space. Change the code with Edit & run, and the result updates as you type.

Exercise 1 · Warm-upCount the Tab stops

How many times does Tab stop in this result? Count first, then click in the result and press Tab to check.

export default function App() {  return (    <>      <div onClick={() => {}}>Menu</div>      <button>Search</button>      <a href="#offers">Offers</a>      <span onClick={() => {}}>Help</span>      <a>Contact</a>      <input aria-label="Email" placeholder="Email" />    </>  );}
ResultLoading…

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the symbol buttons

    Give the two buttons the names One less Tea and One more Tea. Before you change anything: what name does the + button have?

    import { useState } from "react"; export default function App() {  const [quantity, setQuantity] = useState(1);   return (    <p>      Tea{" "}      <button onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>      {" "}{quantity}{" "}      <button onClick={() => setQuantity(quantity + 1)}>+</button>    </p>  );}
    ResultLoading…

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFix the shared id

      Click Delivery note: the cursor goes into the first box. Fix NoteField with useId. Which box gets the cursor when you click Delivery note now?

      function NoteField({ label }) {  return (    <p>      <label htmlFor="note">{label}</label>      <br />      <textarea id="note" rows={2} />    </p>  );} export default function App() {  return (    <>      <NoteField label="Gift note" />      <NoteField label="Delivery note" />    </>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeA toggle that works from the keyboard

        The delivery details toggle is a div. Make it a button that a keyboard can press and a screen reader can describe, with aria-expanded. What is aria-expanded after one press?

        import { useState } from "react"; export default function App() {  const [open, setOpen] = useState(false);   return (    <>      <div className="fake-button" onClick={() => setOpen(!open)}>        {open ? "Hide" : "Show"} delivery details      </div>      {open && <p>Orders before 2 pm leave the same day.</p>}    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeKeep the focus after a remove

          Remove Lemon cake from the keyboard: the focus falls to the page. Move it to the next wish's button instead, or the heading when the list is empty. After you remove Lemon cake, which button has the focus?

          import { useState } from "react";import FocusReport from "./FocusReport.jsx"; const startWishes = [  { id: 1, name: "Green tea" },  { id: 2, name: "Lemon cake" },  { id: 3, name: "Mint tea" },]; export default function App() {  const [wishes, setWishes] = useState(startWishes);   function remove(index) {    setWishes(wishes.filter((_, i) => i !== index));  }   return (    <>      <h2>Wishlist</h2>      <ul>        {wishes.map((wish, index) => (          <li key={wish.id}>            {wish.name}{" "}            <button onClick={() => remove(index)}>Remove {wish.name}</button>          </li>        ))}      </ul>      <FocusReport />    </>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeAn error a screen reader can find

            Press Apply with an empty box: a message appears, but nothing joins it to the box. Join it, mark the box invalid, and move the focus to the box. After an empty Apply, what does a screen reader read as the box's description?

            import { useState } from "react"; export default function App() {  const [error, setError] = useState("");   function handleSubmit(event) {    event.preventDefault();    const code = new FormData(event.currentTarget).get("coupon");    setError(code.trim() ? "" : "Enter a coupon code.");  }   return (    <form onSubmit={handleSubmit}>      <label>        Coupon code <input name="coupon" />      </label>{" "}      <button>Apply</button>      {error && <p className="error">{error}</p>}    </form>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, picture someone using only a keyboard, and someone using a screen reader.

              The Accessible React components quiz · 8 questionsScore: first tries count
              1. Question 1 of 8Why does Tab skip a <div> that has an onClick?

                Choose an answer to see the explanation.

              2. Question 2 of 8What name does a screen reader give this button?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                <button aria-label="Close cart" onClick={close}>
                  ×
                </button>

                Choose an answer to see the explanation.

              3. Question 3 of 8A QuantityField component appears three times on a page. Why give it useId() instead of id="quantity"?

                Choose an answer to see the explanation.

              4. Question 4 of 8A keyboard user presses a row's Remove button, and this handler runs. Where is the focus afterwards?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                function remove(id) {
                  setItems(items.filter((item) => item.id !== id));
                }

                Choose an answer to see the explanation.

              5. Question 5 of 8SaveStatus is on the page from the start, with saved false. What happens when saved turns true?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                function SaveStatus({ saved }) {
                  return <p role="status">{saved ? "Draft saved." : ""}</p>;
                }

                Choose an answer to see the explanation.

              6. Question 6 of 8What does aria-describedby={errorId} on an input do?

                Choose an answer to see the explanation.

              7. Question 7 of 8In the checkout form, why does handleSubmit call flushSync before it focuses the first wrong field?

                Choose an answer to see the explanation.

              8. Question 8 of 8Which tool can tell you, as you type, that a <div> has an onClick and no keyboard handler?

                Choose an answer to see the explanation.

              Key takeaways

              • React renders the elements you write. Accessibility starts with choosing the right ones in JSX.
              • Use a <button> for actions and an <a href> for going somewhere. A div with onClick misses Tab, keys, a role, and disabled.
              • Every control needs a name: its text, its label, or an aria-label when it shows only a symbol.
              • Make ids with useId: one per copy of a component, the same on the server and in the browser.
              • When an action removes the focused element or opens something, move the focus with a ref. <dialog> with showModal() handles a dialog's focus for you.
              • Announce changes with a role="status" region that's on the page from the start.
              • Join errors with aria-describedby and aria-invalid, and focus the first wrong field after flushSync.
              • Test with the keyboard, eslint-plugin-jsx-a11y, axe, and a screen reader. Each finds what the others miss.

              Use real elements, name every control, and move and announce what changes.
              Then a keyboard and a screen reader can do everything a mouse can.

              Coming next: Class components and older patterns. It shows how to read and maintain React code written before Hooks.

              CompleteFrontend Clear concepts. Working examples.