Topic 18.1
Accessible React Starts with HTML
In one line
React renders plain HTML, so most accessibility comes free when your components output the right elements: real buttons, labelled inputs, headings and landmarks, with Fragments instead of wrapper divs that break the structure.
Think of it like this
A TV remote. A good remote has buttons you can feel (they stick out and click) with labels on them (Volume, Channel). Now imagine a remote that is one flat glass panel with no labels: it works if you can see the drawings, and is useless in the dark. A <div> styled to look like a button is that glass panel. A real <button> is the button you can feel: it works with a mouse, a keyboard and a screen reader, in the dark.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Accessibility (a11y)
- Making an app usable by everyone, including people who use a keyboard, a screen reader or other assistive technology.
- Assistive technology
- Tools people use to reach a computer differently: screen readers, switches, voice control, magnifiers.
- Screen reader
- Software that reads the page aloud and lets you jump between headings, links, buttons and form fields.
- Accessibility tree
- The browser's simplified copy of the page, with a role, a name and states for each element. Assistive technology reads this, not the pixels.
- Role
- What an element is to assistive technology: button, link, heading, textbox, dialog…
- Accessible name
- What an element is called when announced, such as "Save" for a button. It comes from content, a label or
aria-label. - Semantic element
- An HTML element whose name says what it is (
button,nav,h2), so the browser knows its role and behaviour. - Div soup
- Markup made almost entirely of
divs, with no meaning for assistive technology. - Landmark
- A page region a screen reader can jump to:
header,nav,main,aside,footer,formwith a name,sectionwith a name. - eslint-plugin-jsx-a11y
- A lint plugin that warns about accessibility mistakes in JSX while you write it.
Step by step
01Start from what the thing is
Before styling anything, name the element. Does clicking it do something on this page? It's a <button>. Does it go somewhere (a new URL)? It's a link (<a href> or React Router's <Link>, which renders an <a>). Is it a group of navigation links? <nav> with a list. Is it the title of a section? A heading at the right level.
Then style it. CSS can make a <button> look like anything: a card, an icon, plain text (all: unset removes the default look; then add a visible focus style back).
function ProductCard({ product, onAdd }) {
return (
<article className="card">
<h3>{product.name}</h3>
<img src={product.image} alt="" /> {/* decorative: the h3 already names it */}
<p>{product.price}</p>
<button type="button" onClick={() => onAdd(product.id)}>
Add {product.name} to cart
</button>
<a href={`/products/${product.id}`}>Details</a>
</article>
);
}02Give every field a label
Use <label htmlFor="id"> with a matching id on the input, or wrap the input in the label. Both give the input an accessible name and make the label text clickable, which also helps people with shaky hands. Keep visible labels: a placeholder is a hint, not a name.
<label htmlFor="email">Email</label>
<input id="email" type="email" autoComplete="email" />
<label>
<input type="checkbox" name="news" /> Send me the newsletter
</label>03Build the page outline with landmarks and headings
Screen reader users jump by landmarks ("main", "navigation") and by headings, the way sighted people scan. Give the app one <main>, a <nav> for menus, and headings in order (one <h1> per page, <h2> for sections, no skipping from <h2> to <h4> just for size: change the size with CSS).
In React the outline is spread across components, so it's easy to break. A Card that always renders <h2> is wrong when it sits under an <h3> section. Let the parent choose: pass a heading level prop (level={3}) or render the heading in the parent.
04Return Fragments, not wrapper divs
When a component's output goes inside a list, a table row or a definition list, return a Fragment so the parent's structure stays valid. If the component is mapped in a list, use <Fragment key={item.id}> (the short <> syntax can't take a key).
function PriceCells({ item }) {
return (
<>
<td>{item.name}</td>
<td>{item.qty}</td>
</>
);
}
// <tr><PriceCells item={item} /></tr> → <tr><td>…</td><td>…</td></tr>05Turn on the lint rules
Install the plugin and add its recommended config to eslint.config.js (ESLint 9 flat config). Your editor now underlines problems as you type. Next.js projects already include it through eslint-config-next.
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
// ...your other configs (React, React Hooks, TypeScript)
jsxA11y.flatConfigs.recommended,
];Try it yourself
- 1
Tab through the buttons
In the first example, click the empty area of the preview and press Tab several times. Which element gets the orange focus ring? Press Enter and Space while it's focused: the count goes up.
Now add
tabIndex={0}to the div. Predict: can you reach it with Tab now? Does Enter work on it? (Tab: yes. Enter: no, because a div has no built-in key behaviour.) - 2
Label the city
In the labels example, give the city input a real label. Predict the new log line for
citybefore you run it. - 3
Swap the Fragment for a div
In the glossary, replace
<Fragment key={t.id}>with<div key={t.id}>. Predict the log. ([ 'DIV', 'DIV' ]. HTML does allow adivaround adt/ddgroup in adl, so React doesn't warn here, but in aul,tableorselecta wrapper div is invalid.)
Code & diagrams
Both look the same and both work with a mouse. Now click in the preview and press Tab: focus jumps straight to the real button. The div can't take focus, and even if you give it `tabIndex={0}`, Enter and Space still do nothing until you write a key handler.
Preview
Console
Nothing logged yet.Expected console output
div | tabIndex: -1 | took focus: false
button | tabIndex: 0 | took focus: true`input.labels` is the browser's own list of labels for a field. `htmlFor` and wrapping both work; the placeholder-only field has none. Browsers may fall back to the placeholder as its name, but that name vanishes from view the moment you type, and tools such as Testing Library's role queries don't count it (Topic 18.5).
Preview
Console
Nothing logged yet.Expected console output
email | labels: 1 | label text: Email
phone | labels: 1 | label text: Phone
city | labels: 0 | label text: (none)
clicking the Email label focused: emailThe Fragment leaves no trace in the DOM: the `dl` gets `dt`, `dd`, `dt`, `dd`, exactly what HTML (and a screen reader announcing "description list, 2 items") expects.
Preview
Console
Nothing logged yet.Expected console output
children of <dl>: [ 'DT', 'DD', 'DT', 'DD' ]$ cat Card.jsx
export function Card({ onOpen }) {
return (
<div className="card" onClick={onOpen}>
<img src="/cat.png" />
<input type="text" />
<a onClick={onOpen}>More</a>
</div>
);
}
$ npx eslint Card.jsx
Card.jsx
3:5 error Visible, non-interactive elements with click handlers must have at least one keyboard listener jsx-a11y/click-events-have-key-events
3:5 error 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 jsx-a11y/no-static-element-interactions
4:7 error img elements must have an alt prop, either with meaningful text, or an empty string for decorative images jsx-a11y/alt-text
6:7 error Anchor used as a button. Anchors are primarily expected to navigate. Use the button element instead. Learn more: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/anchor-is-valid.md jsx-a11y/anchor-is-valid
6:7 error Visible, non-interactive elements with click handlers must have at least one keyboard listener jsx-a11y/click-events-have-key-events
6:7 error 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 jsx-a11y/no-static-element-interactions
✖ 6 problems (6 errors, 0 warnings)
# Note what it did NOT catch: the <input> has no label. The rule
# label-has-associated-control checks <label> elements, and there is none here.
# Lint sees JSX only; Topic 18.5 adds checks on the rendered page.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Wrap table cells in a div
Write a Row component that returns <div><td>{name}</td><td>{qty}</td></div> and render it inside a <tr>.
Break #2
Build a button from a span
Replace a <button onClick={remove}>Delete</button> with <span className="btn" onClick={remove}>Delete</span>.
Myth vs fact
Myth
React apps are less accessible than plain HTML pages.
Fact
React outputs the same DOM you would write by hand. Apps become inaccessible when components render the wrong elements or mismanage focus, not because of React itself.
Myth
Adding role="button" makes a div a button.
Fact
A role only changes what is announced. It adds no focus, no Enter/Space behaviour and no form behaviour. You must add all of that yourself, which is why a real <button> is better.
Myth
A placeholder is enough as a label.
Fact
Placeholders vanish while typing, are often too faint to read, and are not reliably announced. Use a visible <label>; keep placeholders for format examples.
Myth
Accessibility is only for blind users.
Fact
Keyboard users, people with motor or cognitive disabilities, older people, people with temporary injuries, and anyone in bright sunlight or with one hand on a baby benefit. Many accessibility fixes are plain usability fixes.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React attaches its event listeners at the root, not on each element, and dispatches synthetic events. That's why
onClickworks on a div at all: React doesn't care whether the element is interactive. The browser does, and it decides focusability and keyboard activation. A<button>firesclickfor Enter (on keydown) and Space (on keyup) natively, soonClickon a button already covers the keyboard. - ▸
<a>withouthrefis not a link: it's not focusable and has no role. React Router's<Link>and Next.js<Link>render a real<a href>, so middle-click, "open in new tab" and screen reader link lists all work. Don't replace them with<button onClick={() => navigate(...)}>for navigation. - ▸
Since React 19 the
inertprop is a real boolean (<div inert>rendersinert=""). In React 18 you had to writeinert="".inertremoves a subtree from focus and the accessibility tree, which is the modern way to hide the page behind a modal (Topic 18.2). - ▸
WCAG (Web Content Accessibility Guidelines) is the standard most laws point to; the current version is WCAG 2.2 (2023). Using correct elements covers many of its success criteria at once, for example 4.1.2 Name, Role, Value and 2.1.1 Keyboard.
Remember this
- 1
Assistive technology is any tool people use to reach a computer in a different way: a screen reader (software that reads the page aloud and lets you move by headings, links and buttons, such as NVDA, JAWS or VoiceOver), keyboard-only use, switch devices, voice control and screen magnifiers. These tools don't look at your pixels. They read the accessibility tree: a simplified copy of the DOM the browser builds, where every element has a role (what it is: button, link, heading), a name (what it's called: "Save") and a state (pressed, expanded, checked). The HTML course (
/html, Topic 7.1) shows this tree in DevTools. - 2
React doesn't have its own accessibility system.
react-domturns your JSX into ordinary DOM elements, and the browser builds the accessibility tree from them. So the single most important rule is: write the HTML element that means what you want.<button>for actions,<a href>for going somewhere,<input>/<select>/<textarea>with a<label>for data entry,<h1>–<h6>for headings, and landmarks (<header>,<nav>,<main>,<footer>,<aside>) for the page regions. Each one brings its role, keyboard behaviour and focus for free. - 3
Div soup is the React-specific trap: components that render a
<div>for everything because a div has no styles and no rules. A clickable<div onClick>has three problems at once. It is not focusable (Tab skips it), it does not react to Enter or Space, and it has no role, so a screen reader announces "Save" as plain text, not as a button. To fix a div by hand you needrole="button",tabIndex={0}, anonKeyDownfor Enter and Space, and disabled handling: about 15 lines to rebuild what<button>gives you in one word. Always writetype="button"on buttons inside a form that shouldn't submit it (the default type issubmit). - 4
Labels connect a visible text to an input so the input has an accessible name and clicking the text focuses the field. In JSX the
forattribute is written **htmlFor** (becauseforis a JavaScript keyword), and you can also wrap the input inside the<label>. Aplaceholderis not a label: it disappears when you type, is often low-contrast, and some screen readers don't announce it. When a component is used many times, hard-coded ids likeid="email"collide; Topic 18.3 fixes that withuseId. - 5
Fragments (
<>…</>or<Fragment key={…}>) let a component return several elements without a wrapper. That matters for accessibility because some HTML elements only allow certain children:<ul>/<ol>need<li>children,<tr>needs<td>/<th>,<dl>needs<dt>/<dd>(optionally grouped in a<div>),<select>needs<option>. A component that wraps its cells in a<div>breaks the table: React warns in development, and screen readers lose the row and column relationships ("row 2, column Qty"). Use a Fragment, and a keyed<Fragment key>when it is the item of a list. - 6
eslint-plugin-jsx-a11y is a lint plugin that reads your JSX and flags common mistakes before you run the app:
onClickon adivwithout keyboard support (click-events-have-key-events,no-static-element-interactions),<img>withoutalt(alt-text),<a>used as a button (anchor-is-valid), misspelled ARIA attributes (aria-props),<label>with no control (label-has-associated-control) and more. Next.js includes it by default, and you add it to a Vite project in a few lines. It can only see your JSX, not the rendered page, so it catches maybe the obvious third of problems; Topic 18.5 covers the rest.
Explain it without notes
Why is <div onClick> an accessibility bug, and what exactly does <button> give you instead?
What is the accessibility tree, and how does React's output reach it?
Why do Fragments matter for accessibility, not only for tidy markup?
What can eslint-plugin-jsx-a11y catch, and what can't it catch?
Practice
Rewrite <div className="icon-btn" onClick={close}><svg …/></div> so it is accessible.
A NavItems component returns <div><li>Home</li><li>About</li></div> and is used inside <ul>. Fix it.
Write a search form with a labelled search field and a submit button, using landmarks correctly.
Trade-offs
- ↔
Native elements bring behaviour you may not want (button default styles,
submittype,<details>animation limits). Resetting their styles is still far less work and risk than rebuilding the behaviour on a div. - ↔
Lint rules sometimes flag valid code (for example a click handler on a wrapper that only forwards clicks to a real button). Disable a rule on one line with a comment explaining why, never for the whole project.
Done when you can
I can explain what the accessibility tree is and how React's DOM feeds it.
I choose
button,a href, labelled inputs, headings and landmarks before reaching for a div.I can list what a clickable div lacks compared with a button.
I use Fragments so lists, tables and definition lists keep valid structure.
I can set up eslint-plugin-jsx-a11y and read its errors.