Command Palette

Search for a command to run...

Hectal
PHASE 18Intermediate ~32 min· topic 1 of 5

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, form with a name, section with 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).

ProductCard.jsxwhole filejsx
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.

SignupForm.jsxwhole filejsx
<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.

Build the page outline with landmarks and headingsdiagram
Rendering diagram…

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).

Cells.jsxwhole filejsx
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.

eslint.config.jswhole filejavascript
import jsxA11y from "eslint-plugin-jsx-a11y";

export default [
  // ...your other configs (React, React Hooks, TypeScript)
  jsxA11y.flatConfigs.recommended,
];

Try it yourself

  1. 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. 2

    Label the city

    In the labels example, give the city input a real label. Predict the new log line for city before you run it.

  3. 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 a div around a dt/dd group in a dl, so React doesn't warn here, but in a ul, table or select a wrapper div is invalid.)

Code & diagrams

A div that looks like a button is not a button New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

div | tabIndex: -1 | took focus: false
button | tabIndex: 0 | took focus: true
Labels give inputs a name (placeholders don't) New tab

`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

Press Run to see the page.

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: email
A keyed Fragment keeps a definition list valid New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

children of <dl>: [ 'DT', 'DD', 'DT', 'DD' ]
What eslint-plugin-jsx-a11y reports (real output, recommended config)bash
$ 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>.

terminal
$ Console
── what you'll see ──
In HTML, <td> cannot be a child of <div>.
This will cause a hydration error.
 
<Cart>
<table>
<tbody>
<tr>
<Row name="Pen" qty={2}>
> <div>
> <td>
...
 
In HTML, <div> cannot be a child of <tr>.
This will cause a hydration error.
 
<Cart>
<table>
<tbody>
> <tr>
<Row name="Pen" qty={2}>
> <div>
 
<tr> cannot contain a nested <div>.
See this log for the ancestor stack trace.

Break #2

Build a button from a span

Replace a <button onClick={remove}>Delete</button> with <span className="btn" onClick={remove}>Delete</span>.

terminal
$ Keyboard and screen reader
── what you'll see ──
Tab skips the control. A screen reader reading the page says "Delete" as plain text, with no hint that it can be pressed. Mouse users notice nothing.

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 onClick works 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> fires click for Enter (on keydown) and Space (on keyup) natively, so onClick on a button already covers the keyboard.

  • ▸

    <a> without href is 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 inert prop is a real boolean (<div inert> renders inert=""). In React 18 you had to write inert="". inert removes 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. 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. 2

    React doesn't have its own accessibility system. react-dom turns 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. 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 need role="button", tabIndex={0}, an onKeyDown for Enter and Space, and disabled handling: about 15 lines to rebuild what <button> gives you in one word. Always write type="button" on buttons inside a form that shouldn't submit it (the default type is submit).

  4. 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 for attribute is written **htmlFor** (because for is a JavaScript keyword), and you can also wrap the input inside the <label>. A placeholder is 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 like id="email" collide; Topic 18.3 fixes that with useId.

  5. 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. 6

    eslint-plugin-jsx-a11y is a lint plugin that reads your JSX and flags common mistakes before you run the app: onClick on a div without keyboard support (click-events-have-key-events, no-static-element-interactions), <img> without alt (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

01

Why is <div onClick> an accessibility bug, and what exactly does <button> give you instead?

02

What is the accessibility tree, and how does React's output reach it?

03

Why do Fragments matter for accessibility, not only for tidy markup?

04

What can eslint-plugin-jsx-a11y catch, and what can't it catch?

Practice

01

Rewrite <div className="icon-btn" onClick={close}><svg …/></div> so it is accessible.

02

A NavItems component returns <div><li>Home</li><li>About</li></div> and is used inside <ul>. Fix it.

03

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, submit type, <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.