Step 19 of 21

Accessibility

Build interfaces everyone can use: native semantics, accessible names, keyboard and focus support, correct ARIA and sufficient contrast.

18 min 9 sections

Accessibility (often shortened to a11y) means people can use your interface whatever their abilities or the way they interact: with a screen reader, a keyboard alone, a switch device, voice control, magnification or high zoom. It is a legal requirement in many places, it improves usability for everyone, and it costs little when built in from the start. This lesson covers how assistive technology reads your page and the techniques that make components work with it.

How assistive technology sees your page

Alongside the DOM, the browser builds an accessibility tree: a simplified version of the page that screen readers and other assistive technology read. Each node in it has three key properties:

  • Role: what it is (button, link, heading, textbox, checkbox).
  • Name: what it is called ("Save", "Search products").
  • State: its current condition (checked, expanded, disabled, invalid).

A screen reader announces a focused control as, for example, "Remember me, checkbox, checked". If your markup does not provide a role, name and state, the user does not get them. You can inspect this tree in the DevTools Accessibility panel.

The reference standard is the Web Content Accessibility Guidelines (WCAG). Most organisations target level AA: laws often still cite WCAG 2.1 AA, and WCAG 2.2 (the current version, which adds a few criteria) is the usual goal for new work. The rules in this lesson are drawn from 2.2 AA.

Semantics first

Native HTML elements come with roles, names, states and keyboard behaviour built in. Custom elements built from divs come with nothing:

Feature<button><div onClick>
Reachable with TabYesNo
Activated by Enter and SpaceYesNo
Announced as "button"YesNo
disabled supportYesNo
Submits a formYesNo

Making the div match takes role="button", tabIndex={0}, key handlers and disabled logic, and you would still miss details. Use the real element.

The same principle applies everywhere:

  • Links go places, buttons do things. If it changes the URL, use <a href>. If it performs an action (open a menu, submit, delete), use <button type="button">.
  • Headings form an outline. Screen reader users jump between headings to scan a page, so use <h1> to <h6> in order and never pick a level for its font size.
  • Landmarks (<header>, <nav>, <main>, <footer>) let users jump straight to regions.

Accessible names

Every interactive element needs a name, and every meaningful image needs a text alternative.

Form fields

A visible <label> connected to the input is the best name. It is read aloud on focus, and clicking it focuses the field. A placeholder is not a label: it disappears as soon as the user types and often has poor contrast.

html
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email"
       aria-describedby="email-hint email-error" aria-invalid="true">
<p id="email-hint">We will only use this to send receipts.</p>
<p id="email-error">Enter an email address like [email protected].</p>

aria-describedby adds text announced after the name, ideal for hints and errors, and aria-invalid="true" announces the field as invalid. In React, generate ids with useId() so reused components never clash.

Images

  • Informative images need alt text describing their content or purpose: alt="Bar chart: sales doubled between March and June".
  • Decorative images need an empty alt, alt="", so screen readers skip them. Leaving alt out entirely makes many screen readers read the file name instead.

Icon-only buttons

A button containing only an icon has no text to name it. Give the button an aria-label and hide the icon itself:

tsx
<button type="button" aria-label="Close dialog" onClick={onClose}>
  <svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20">
    <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" />
  </svg>
</button>

Keyboard support and focus

Many users never touch a mouse. Everything you can do with a pointer must be possible with the keyboard, and the user must always see where focus is.

  • Tab order follows DOM order. Keep the source order logical rather than rearranging visually with CSS in ways that make focus jump around.
  • tabindex="0" adds a custom element to the tab order; tabindex="-1" makes an element focusable from script without adding it to the tab order. Never use a positive tabindex: it overrides the natural order and creates confusing jumps.
  • Never remove focus outlines without a replacement. Use :focus-visible, which shows the ring for keyboard users without showing it on every mouse click:
css
:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 2px;
}

A skip link is the first focusable element on the page and jumps past repeated navigation: <a class="skip-link" href="#main">Skip to main content</a>, pointing at <main id="main" tabindex="-1">. Position it off-screen and bring it into view on :focus.

Managing focus

When content changes significantly, move focus deliberately. After a client-side route change, move focus to the new page's <h1> (give it tabIndex={-1}) so screen reader users know the page changed. When a modal opens, focus must move into it, stay inside it, and return to the trigger when it closes. The native <dialog> element does all of that when opened with showModal(), and closes on Escape:

tsx
import { useId, useRef } from 'react'

export function ConfirmDelete({ onConfirm }: { onConfirm: () => void }) {
  const dialogRef = useRef<HTMLDialogElement>(null)
  const titleId = useId()

  return (
    <>
      <button type="button" onClick={() => dialogRef.current?.showModal()}>
        Delete project
      </button>
      <dialog ref={dialogRef} aria-labelledby={titleId}>
        <h2 id={titleId}>Delete this project?</h2>
        <p>This cannot be undone.</p>
        <form method="dialog">
          <button value="cancel">Cancel</button>
          <button value="confirm" onClick={onConfirm}>Delete</button>
        </form>
      </dialog>
    </>
  )
}

A <form method="dialog"> closes its dialog when submitted, so both buttons close it without extra code.

ARIA: the rules and a real widget

ARIA (Accessible Rich Internet Applications) is a set of attributes that set roles, names and states in the accessibility tree. It changes only what assistive technology is told; it adds no behaviour. role="button" does not make a div focusable or clickable.

ARIA earns its place when you build patterns HTML lacks. A disclosure, a button that shows and hides a panel, needs to announce whether it is expanded:

tsx
import { useId, useState, type ReactNode } from 'react'

export function Disclosure({ title, children }: { title: string; children: ReactNode }) {
  const [open, setOpen] = useState(false)
  const panelId = useId()

  return (
    <div>
      <h3>
        <button
          type="button"
          aria-expanded={open}
          aria-controls={panelId}
          onClick={() => setOpen((o) => !o)}
        >
          {title}
        </button>
      </h3>
      <div id={panelId} hidden={!open}>
        {children}
      </div>
    </div>
  )
}

A screen reader announces "Shipping details, button, collapsed", then "expanded" after activation. The native <details> and <summary> elements give you this for free when you do not need custom behaviour.

Live regions

Content that changes without a page load, such as "Saved", search result counts or form errors, is invisible to screen reader users unless announced. A live region announces changes to its content:

tsx
<p role="status">{saved ? 'All changes saved' : ''}</p>

role="status" is polite: it waits until the user is idle. role="alert" interrupts immediately, so reserve it for urgent errors. The region must already exist in the DOM before its content changes; inserting a new element that already contains the text is often not announced.

Finally, aria-hidden="true" removes an element from the accessibility tree. Use it for decorative content, and never on something focusable, or keyboard users will land on an element that screen readers say does not exist.

Colour, contrast and motion

Text needs enough contrast for people with low vision, or anyone using a phone in sunlight. WCAG AA requires:

ContentMinimum contrast ratio
Normal text4.5 : 1
Large text (24px+, or 18.66px+ bold)3 : 1
UI components and meaningful graphics (borders of inputs, focus rings, icons)3 : 1

For example, #767676 on white is about 4.5:1, the lightest grey that passes for body text. DevTools shows the ratio when you inspect a colour.

Never use colour alone to convey meaning. A field that only turns red on error is invisible to many colour-blind users; add error text. Likewise, keep links in paragraphs underlined.

Respect users who ask for less motion, since large animations can cause nausea:

css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Also check that the page works at 200% zoom and at a 320px-wide viewport without horizontal scrolling, and that click targets are at least 24 by 24 CSS pixels.

Testing accessibility

Automated tools catch only some problems, such as missing labels and low contrast. They cannot tell whether your alt text makes sense or your focus order is logical. Combine three methods:

  1. Automated checks: the axe DevTools extension or Lighthouse audit, eslint-plugin-jsx-a11y in your editor, and axe in end-to-end tests.
  2. A keyboard pass: put the mouse away and Tab through the page. Can you reach and operate everything? Is focus always visible? Does it ever get trapped or lost?
  3. A screen reader pass: VoiceOver is built into macOS (Cmd+F5) and iOS, NVDA is free on Windows, and TalkBack ships with Android. Navigate by headings and controls and listen to what is announced.

Practice

This newsletter component has at least five accessibility problems. Find them, then rewrite it.

tsx
import { useState } from 'react'

export function Newsletter() {
  const [error, setError] = useState(false)
  return (
    <div className="card">
      <img src="/envelope.svg" />
      <div className="title">Join our newsletter</div>
      <input placeholder="Email" className={error ? 'border-red' : ''} />
      <div className="btn" onClick={() => setError(true)}>Subscribe</div>
    </div>
  )
}
Show solution

The problems:

  1. The decorative image has no alt, so screen readers may read "envelope.svg".
  2. The title is a div, missing from the heading outline.
  3. The input has a placeholder but no label.
  4. The error is a red border alone: colour-only, never announced, not tied to the field.
  5. The "button" is a div: not focusable, not keyboard-operable, not announced as a button.
  6. There is no <form>, so pressing Enter does nothing.
tsx
import { useId, useState, type FormEvent } from 'react'

export function Newsletter() {
  const [error, setError] = useState('')
  const titleId = useId()
  const emailId = useId()
  const errorId = useId()

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const email = String(new FormData(e.currentTarget).get('email') ?? '')
    setError(email.includes('@') ? '' : 'Enter an email address like [email protected].')
  }

  return (
    <section className="card" aria-labelledby={titleId}>
      <img src="/envelope.svg" alt="" />
      <h2 id={titleId}>Join our newsletter</h2>
      <form onSubmit={handleSubmit} noValidate>
        <label htmlFor={emailId}>Email</label>
        <input
          id={emailId}
          name="email"
          type="email"
          autoComplete="email"
          aria-invalid={error ? true : undefined}
          aria-describedby={errorId}
          className={error ? 'border-red' : ''}
        />
        <p id={errorId} role="alert">
          {error && `Error: ${error}`}
        </p>
        <button type="submit">Subscribe</button>
      </form>
    </section>
  )
}

The error paragraph is always rendered, so its live region exists before text appears and the message is announced. It is linked to the input with aria-describedby, so it is read again whenever the field is focused, and the visible "Error:" text means the problem no longer depends on colour.

Key takeaways

  • Assistive technology reads the accessibility tree: every control needs the right role, name and state.
  • Native elements first: <button>, <a href>, <label>, headings and landmarks give you semantics and keyboard support for free.
  • Give every input a visible label, every meaningful image alt text and every icon button an aria-label.
  • Everything must work with a keyboard, with visible focus, no positive tabindex and deliberate focus management.
  • ARIA describes but never adds behaviour; use it for patterns HTML lacks, such as aria-expanded and live regions.
  • Meet 4.5:1 contrast for text, never rely on colour alone, and test with a keyboard and a screen reader, not only automated tools.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.You need a clickable control that opens a menu. Which implementation is best?

  2. 2.How should you give an accessible name to a button that contains only an SVG trash icon?

  3. 3.A form shows 'Profile saved' after an async request, but screen reader users never hear it. The message is rendered as `{saved && <p role="status">Profile saved</p>}`. What is the most likely fix?

  4. 4.Under WCAG 2.2 AA, what is the minimum contrast ratio for regular 16px body text?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.