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.
- 01Pick 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.
- 02Name controls and connect themGive icon buttons a name with aria-label, join labels and errors to fields with useId, aria-describedby, and aria-invalid.
- 03Manage 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.
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> </> );}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.
<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.
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
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:
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> </> );}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.
<div className="fake-button" onClick={addTea}>
Add tea
</div>Items in cart: 0Not 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
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.
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.
| What you need | Use | What 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.
| In HTML | In JSX | Why |
|---|---|---|
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:
export default function App() { return ( <ul> <li> Tea <button>×</button> </li> <li> Cake <button aria-label="Remove Cake">×</button> </li> </ul> );}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:
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" /> </> );}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.
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:
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" /> </> );}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:
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.
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 /> </> );}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:
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 /> </> );}- Line 12 is a ref for the heading, and line 24 lets code focus it:
tabIndex={-1}makes an element focusable byfocus()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
keydoesn't change, sofocus()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.
<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():
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 /> </> );}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.
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> </> );}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.
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:
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> </> );}line 17- Press Add tea, then Add cake.
The status paragraph is on the page from the start, empty. Each click only changes its text, so each message is read out.
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.
| Write | How it speaks | Use 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:
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> );}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 setsaria-describedbyto the error's id, so it reads the message after the label. Line 18 gives the message that id. With no error, both areundefined, and React leaves them out. - Line 48's
noValidateturns 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 withcheck. Line 41 sets the errors insideflushSync, which makes React put them on the page before the next line runs. Line 43 then focuses the first wrong field, found by itsnameinform.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: noneunless you draw another one in its place, for example with the:focus-visibleselector. - 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:
// 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:
<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.
| Check | What it finds | What it misses |
|---|---|---|
| Using only the keyboard | Controls Tab skips, keys that do nothing, lost focus, no visible focus ring. | Whether names and messages make sense when read aloud. |
| eslint-plugin-jsx-a11y | A 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.
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> );}- Lines 39 to 45 are the − and + buttons. They show symbols, so
aria-labelnames 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, saysRemoved Tea., and moves the focus. - Line 31's
aria-labelnames 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.
<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>
Sort each card by what a keyboard or screen reader user needs: a real element, a name, the focus moved, or a change announced.
Common mix-ups
- "role="button" turns a div into a button." No. It only changes what screen readers say. Tab, Enter, Space, and
disabledstill 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
0and-1. - "The focus ring looks untidy, so remove it." No. Without it, keyboard users can't tell where they are. Restyle it with
:focus-visibleinstead. - "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.
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" /> </> );}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" /> </> );}Tab stops on the Search button (line 5), the Offers link (line 6), and the Email box (line 9): 3 stops. The div on line 4 and the span on line 7 aren't controls, whatever their onClick says, and an <a> without href on line 8 isn't a link the browser can focus.
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> );}import { useState } from "react"; export default function App() { const [quantity, setQuantity] = useState(1); return ( <p> Tea{" "} <button onClick={() => setQuantity(Math.max(1, quantity - 1))} aria-label="One less Tea"> − </button> {" "}{quantity}{" "} <button onClick={() => setQuantity(quantity + 1)} aria-label="One more Tea"> + </button> </p> );}Before the fix, each button's name is its only text: the symbols − and +, read as "minus" and "plus". aria-label replaces them with names that say what the buttons do: One less Tea and One more Tea. The result looks the same; only what a screen reader says changes.
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" /> </> );}import { useId } from "react"; function NoteField({ label }) { const id = useId(); return ( <p> <label htmlFor={id}>{label}</label> <br /> <textarea id={id} rows={2} /> </p> );} export default function App() { return ( <> <NoteField label="Gift note" /> <NoteField label="Delivery note" /> </> );}Line 4 gives each NoteField its own id, and lines 7 and 9 use it for the label and the box. Now clicking Delivery note puts the cursor in the second box, and a screen reader names each box after its own label.
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>} </> );}import { useId, useState } from "react"; export default function App() { const [open, setOpen] = useState(false); const detailsId = useId(); return ( <> <button aria-expanded={open} aria-controls={detailsId} onClick={() => setOpen(!open)}> {open ? "Hide" : "Show"} delivery details </button> <p id={detailsId} hidden={!open}> Orders before 2 pm leave the same day. </p> </> );}The button on line 9 is a Tab stop, and Enter and Space press it. aria-expanded={open} makes a screen reader say "collapsed" or "expanded"; after one press it's "true". aria-controls points at the paragraph through an id from useId, and hidden={!open} keeps the paragraph on the page, so that id always exists.
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 /> </> );}import { useRef, 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); const heading = useRef(null); const buttons = useRef(new Map()); function remove(index) { const next = wishes[index + 1] ?? wishes[index - 1]; setWishes(wishes.filter((_, i) => i !== index)); if (next) buttons.current.get(next.id).focus(); else heading.current.focus(); } return ( <> <h2 ref={heading} tabIndex={-1}> Wishlist </h2> <ul> {wishes.map((wish, index) => ( <li key={wish.id}> {wish.name}{" "} <button ref={(node) => { buttons.current.set(wish.id, node); return () => buttons.current.delete(wish.id); }} onClick={() => remove(index)} > Remove {wish.name} </button> </li> ))} </ul> <FocusReport /> </> );}Line 16 picks the next wish, or the one before. Line 18 focuses its button, which is already on the page; line 19 focuses the heading when the list is empty. After you remove Lemon cake, the focus is on Remove Mint tea, and the Focus line says so.
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> );}import { useId, useState } from "react";import { flushSync } from "react-dom"; export default function App() { const [error, setError] = useState(""); const errorId = useId(); function handleSubmit(event) { event.preventDefault(); const form = event.currentTarget; const code = new FormData(form).get("coupon"); flushSync(() => setError(code.trim() ? "" : "Enter a coupon code.")); if (!code.trim()) form.elements.coupon.focus(); } return ( <form onSubmit={handleSubmit}> <label> Coupon code{" "} <input name="coupon" aria-invalid={error ? true : undefined} aria-describedby={error ? errorId : undefined} /> </label>{" "} <button>Apply</button> {error && ( <p id={errorId} className="error"> {error} </p> )} </form> );}Lines 22 and 23 mark the box invalid and point at the message, whose id is on line 28. Line 12 commits the error before line 13 moves the focus, so the box is read with its label, "invalid", and its description: Enter a coupon code.
Check your understanding
For each question, picture someone using only a keyboard, and someone using a screen reader.
Question 1 of 8Why does Tab skip a
<div>that has anonClick?Choose an answer to see the explanation.
Question 2 of 8What name does a screen reader give this button?
Read the code, then predict<button aria-label="Close cart" onClick={close}> × </button>Choose an answer to see the explanation.
Question 3 of 8A
QuantityFieldcomponent appears three times on a page. Why give ituseId()instead ofid="quantity"?Choose an answer to see the explanation.
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 predictfunction remove(id) { setItems(items.filter((item) => item.id !== id)); }Choose an answer to see the explanation.
Question 5 of 8
SaveStatusis on the page from the start, withsavedfalse. What happens whensavedturns true?Read the code, then predictfunction SaveStatus({ saved }) { return <p role="status">{saved ? "Draft saved." : ""}</p>; }Choose an answer to see the explanation.
Question 6 of 8What does
aria-describedby={errorId}on an input do?Choose an answer to see the explanation.
Question 7 of 8In the checkout form, why does
handleSubmitcallflushSyncbefore it focuses the first wrong field?Choose an answer to see the explanation.
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, anddisabled. - Every control needs a name: its text, its label, or an
aria-labelwhen 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>withshowModal()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-describedbyandaria-invalid, and focus the first wrong field afterflushSync. - 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.