Skip to content
Software Atlas

Accessibility

Why a div that looks like a button isn't one, how the browser describes your page to screen readers, and how to build keyboard-friendly, well-labelled, high-contrast UI — then prove it with real tests.

⏱ ~50 min read14 sections14 interactive widgetsHTML · CSS · JS · JSX
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

Here's a bug that ships to production every day. The checkout's “Pay” button is a <div> with a click handler. It looks perfect. It works perfectly — with a mouse. For everyone using a keyboard or a screen reader, there is no button, so there is no sale.

In one sentenceAccessibility means everyone can perceive and operate your UI, whatever they use to do it. The cheapest way there is to use the HTML element that already does the job.

Accessibility (often shortened to a11y: “a”, 11 letters, “y”) is about people who use the web differently from you. Some never touch a mouse and drive everything with the Tab key. Some can't see the screen and use a Screen reader, software that reads the page aloud. Some zoom the page to 400%. You'll meet them all properly in §02.

Try it yourself. Both controls below look identical. Use only your keyboard:

Two “Pay $42” buttons. Only one is a button.
Use only your keyboard: focus “Start here”, then press Tab, Enter and Space. Then try the mouse.
A · <div onClick>✕ broken on purpose
Pay $42
B · <button>✓ a real button
What happened

Nothing yet. Press “Start here”, then Tab.

What each version gives a user (screen reader column from Chromium's accessibility tree)
VersionMouseTabEnter/SpaceScreen reader hears
A · div✓✕ skips it✕ nothing“Pay $42” as plain text
B · button✓✓ stops✓ clicks“Pay $42, button”

The difference is the element. A <button> comes with a lot built in: it's a stop on the Tab key, Enter and Space press it, and it tells assistive software “I'm a button called Pay $42”. A <div> comes with none of that. It's a plain box that happens to be painted blue.

1

Use the real element

<button>, <a href>, <label>, <h1>, <nav>. They come with keyboard support and meaning built in (§03).
2

Name, role, state

Every control must say what it is, what it's called and what state it's in. Screen readers speak exactly that (§04).
3

Keyboard and focus

Everything a mouse can do, Tab, Enter, Space, the arrow keys and Escape must do too, with a visible focus ring (§06, §07).
Foundations§02

Who uses what

“Accessibility” can sound like one special group of users. It's really many groups, each using a different tool, and each tool breaks on different mistakes. Here are the people you're building for, and what each of them needs from your code.

The general name for these tools is Assistive technology (AT): software or hardware that lets someone use a computer in a different way.

⌨️

Keyboard only

Priya has a tremor, so a mouse is hard to aim. She uses Tab to move, Enter/Space to press, arrows inside menus. Breaks on: clickable divs, invisible focus, menus that only open on hover.
🔊

Screen reader

Sam is blind and uses a Screen reader — NVDA or JAWS on Windows, VoiceOver on Mac and iPhone, TalkBack on Android. He hears the page and jumps by headings, links and form fields. Breaks on: missing labels, divs pretending to be buttons, updates that happen silently.
🔍

Zoom and magnification

Lena has low vision. She zooms the browser to 300% or uses a Screen magnifier that shows a small part of the screen at a time. Breaks on: low contrast, text in images, layouts that overflow sideways when zoomed.
🎙️

Voice control

Marco has RSI and uses Voice control: he says “click Pay”. The software finds the button by its name. Breaks on: icon buttons with no name, and names that differ from the visible text.
🌀

Motion sensitivity

Ana has a Vestibular disorder. Parallax scrolling and big zoom animations make her dizzy, so she has switched on “reduce motion” in her OS settings. Breaks on: sites that ignore that setting.
🧠

Cognitive load

Tom has ADHD; his dad is 78. Both struggle with timeouts, jargon, and errors that don't say how to fix them. Breaks on: “Invalid input”, forms that wipe themselves, inconsistent layouts.

It's not a small group, and it's not “them”

The World Health Organization estimates that around 1.3 billion people (about 1 in 6) live with a significant disability. Add the temporary cases (a broken arm, an eye infection) and the situational ones (holding a baby in one arm, bright sunlight, a loud train). At some point, almost everyone uses the web with a limitation.

Foundations§03

Semantic HTML first

In one sentenceHTML elements aren't just boxes with default styles. Each one tells the browser what it is, and the browser passes that on to assistive technology.

Semantic HTML means picking the element that matches what the thing is: a button for an action, a link for navigation, a heading for a heading. The opposite is “div soup”: everything is a <div> or <span>, and CSS and JavaScript fake the rest.

The browser turns your HTML into a second, simpler tree for assistive technology: the Accessibility tree. You'll see how it's built in §04. For now, look at what it contains for the same checkout written both ways. Toggle between them:

Same checkout, two kinds of markup
The right-hand side is what the browser hands to assistive technology — captured from headless Chromium, not drawn by hand.
<div class="header">
  <div class="logo">ShopCo</div>
  <div class="nav">
    <div onclick="go('/cart')">Cart</div>
    <div onclick="go('/help')">Help</div>
  </div>
</div>
<div class="content">
  <div class="title">Checkout</div>
  <div class="field">
    <div>Email</div>
    <input type="email">
  </div>
  <div class="check">
    <div class="box" onclick="toggle()"></div> Save my details
  </div>
  <div class="btn" onclick="pay()">Pay $42</div>
</div>
Accessibility tree
  • text: ShopCo Cart Help Checkout Email
  • textbox (no name!)
  • text: Save my details Pay $42
Tab stops, in order
  1. 1. textbox (unnamed)
What a screen reader can jump to
Landmarks
none
Headings
none
Form controls
1 textbox with no name

Tab reaches one thing — the unnamed email box, announced as just “edit text”. The links, the checkbox and the Pay button don't exist for the keyboard or the screen reader: they're anonymous text.

The div-soup version looks identical on screen. But to a screen reader it's two lines of anonymous text and one mystery text box. Nothing tells it which words are links, which is the heading, or that “Pay $42” can be pressed.

Button or link?

These two get mixed up more than any others. The rule is short:

If clicking it…UseKeyboard you get for free
goes somewhere (a new page, a new URL, a spot on the page)<a href="/cart">Enter follows it; right-click → open in new tab works
does something here (submit, open a menu, add to cart, toggle)<button type="button">Enter and Space press it; it can be disabled
✕ Don't
<!-- Action pretending to be a link -->
<a href="#" onclick="addToCart()">Add to cart</a>

<!-- Navigation pretending to be a button -->
<div class="btn" onclick="location = '/cart'">
  View cart
</div>
✓ Do
<!-- Action → button -->
<button type="button" onclick="addToCart()">
  Add to cart
</button>

<!-- Navigation → link -->
<a href="/cart">View cart</a>

Headings and landmarks: the page's table of contents

Screen reader users rarely listen to a page top to bottom. They pull up a list of headings (in NVDA, the H key jumps to the next one) and skim, the way you'd skim bold text. So:

  • Use <h1>–<h6> for real headings, in order. One <h1> for the page title, <h2> for sections, <h3> inside those. Don't skip levels because <h4> “looks the right size”. Change the size with CSS instead.
  • Don't use a heading tag just to make text big or bold.

Landmarks are the big regions of a page. The elements below create them automatically. The “landmark role” column is what the browser tells screen readers each region is (roles are explained in §04). aria-label is an attribute that gives an element a name to be announced (§04 and §05 cover it):

ElementLandmark roleTypical content
<header> (top level)bannerlogo, site-wide search
<nav>navigationmenus. With more than one, label each: aria-label="Main"
<main>mainthe page's unique content. Exactly one per page
<aside>complementaryrelated links, sidebars
<footer> (top level)contentinfocopyright, legal links
<search>searcha search form (newer element; Chrome 118+, Firefox 118+, Safari 17+)

Every input needs a label

Clicking a <label> focuses its input, and the label's text becomes the input's name. That's what a screen reader reads. There are two ways to connect them:

<!-- explicit: for="…" points at the input's id -->
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email">

<!-- implicit: wrap the input -->
<label><input type="checkbox"> Save my details</label>
Practitioner§04

The accessibility tree: name, role, state

In one sentenceAssistive technology never sees your pixels. It sees a tree of nodes, and each node answers four questions: what is it (role), what's it called (name), what state is it in, and what's its value.

The browser already builds the DOM to draw the page. From the DOM it builds a second, slimmer tree: the Accessibility tree. Purely decorative wrappers, hidden elements and aria-hidden content are left out. Each node that's kept gets four properties:

PropertyQuestionExamples
RoleWhat kind of thing is it?button, link, checkbox, heading, navigation, dialog
Accessible nameWhat's it called?“Pay $42”, “Email”, “Main”
StateWhat's going on with it right now?checked, expanded, disabled, invalid, pressed
ValueWhat's in it?the text typed into a field, a slider's number

Step through how one checkbox travels from HTML to speech:

From HTML to speech
How one checkbox reaches a screen reader. Step with the buttons, or focus the widget and use ← →.
Code
1<label>
2 <input type="checkbox" checked>
3 Email me order updates
4</label>
DOM
label
└ input type=checkbox checked
└ #text “Email me order updates”
Accessibility node
not built yet
Assistive technology
nothing yet
The browser parses the HTML into the DOM: a label element holding an input and some text. So far this is the same tree the browser uses to draw the page.
1 / 7

The browser talks to assistive technology through the operating system's Accessibility API, never through your pixels. That's why the tree is all that matters to a screen reader.

The step that matters most: the role came free with the element. Every HTML element has an Implicit role. <button> is a button, <a href> is a link, <input type="checkbox"> is a checkbox. A <div> has the role “generic”, which assistive technology treats as meaningless.

How the accessible name is chosen

A control can get its name from several places. When more than one is present, the browser follows a fixed priority order. For a text input, highest priority first:

  1. aria-labelledby="id1 id2": joins the text of the elements with those ids.
  2. aria-label="…": a string you write that is never shown on screen.
  3. A <label>, connected with for/id or by wrapping the input.
  4. The title attribute, which is also a mouse-hover tooltip.
  5. The placeholder, the grey hint text inside the field.

Buttons and links also take their name from their content (the text inside them, or an image's alt). That step comes after aria-label and before title. Play with the ladder:

Who wins the name?
Switch the naming sources on and off. Results match what headless Chromium computed for all 32 combinations.
Naming sources, highest priority first
<h2 id="h">Find products</h2>
<label for="q">Search</label>
<input id="q" type="search"
       placeholder="e.g. headphones">
Accessibility treesearchbox “Search”Description: noneBest for sighted and screen-reader users alike: the name is the visible text, and clicking the label focuses the input.
Live input with exactly these attributes

Find products

A few results worth remembering (each one checked in headless Chromium):

MarkupName the browser computes
<button>Save</button>“Save”
<button aria-label="Close dialog">X</button>“Close dialog”. The visible “X” is ignored
<button><svg>…</svg></button>no name at all
<a href="/"><img src="logo.png" alt="Home"></a>“Home”, taken from the image's alt
<span id="a">Delete</span><span id="b">invoice-42.pdf</span><button aria-labelledby="a b">🗑</button>“Delete invoice-42.pdf”
<div aria-label="Card">Hello</div>dropped: a plain div with no role can't have a name, so the label is silently ignored (see the ARIA table in §05)

Descriptions: the second line

A name should be short (“Password”). Extra help goes in the Accessible description, which screen readers read after the name, role and state:

<label for="pw">Password</label>
<input id="pw" type="password" aria-describedby="pw-hint">
<p id="pw-hint">At least 8 characters.</p>
<!-- NVDA (roughly): "Password, edit, protected, At least 8 characters." -->
Practitioner§05

ARIA: powerful, and easy to make worse

In one sentenceARIA changes what assistive technology is told. It never changes what the element does. A div with role="button" is announced as a button, but it still ignores Enter and Space.

ARIA attributes come in three kinds:

KindWhat it saysExamples
Roleswhat this element isrole="dialog", role="tab", role="status"
Stateswhat's true right now, changing as you use itaria-expanded="true", aria-pressed, aria-checked, aria-invalid, aria-disabled
Propertiesrelationships and labels, which rarely changearia-label, aria-labelledby, aria-describedby, aria-controls

The first rule of ARIA

The W3C's own guide states it plainly: if a native HTML element already has the meaning and behaviour you need, use it instead of adding ARIA. Its corollary is just as famous: no ARIA is better than bad ARIA. WebAIM's yearly scan of the top million home pages keeps finding that pages with ARIA have more detected errors than pages without it, on average.

Why can ARIA make things worse? Because it overrides what the browser would have said, and nothing checks that your claims are true:

MarkupWhat the user gets (checked in Chromium)
<div role="button">Pay</div>Announced as “Pay, button”, but Tab skips it and Enter does nothing. A promise that's broken.
<h2 role="button">Details</h2>Now a button called “Details”, and no longer a heading. It's gone from the headings list.
<div aria-hidden="true"><button>Buy</button></div>Removed from the tree, but still reachable with Tab. Focus lands on something the screen reader can't describe.
<div aria-label="Card">Hello</div>A plain div has no role, and ARIA doesn't allow naming one, so Chromium ignores the label. The screen reader reads just “Hello”.
<button role="button">OK</button>Harmless but pointless: the role is already button.
<ul role="menu"> around site navigation linksScreen readers switch into “application menu” mode and expect arrow keys, which your links don't support.

When ARIA is the right tool: states the HTML can't express

The legitimate job for ARIA is describing custom widgets and changing state that HTML has no element for. The classic example is a “show more” toggle. A <button> gives you the role, the focus and the keyboard. aria-expanded adds the one thing HTML can't express: “open” or “closed”.

A “show more” toggle, two ways
Open and close it with the mouse, then try with Tab + Enter. Watch the accessibility node on the right.
<div class="toggle" onclick="toggle()">
  Shipping details ▾
</div>
<div class="panel" style="display:none">
  Ships in 2 working days.
</div>
✕ Deliberately broken demo
Shipping details ▾
Accessibility node for the toggle
role
none (generic text)
name
none
expanded
unknown
focusable
no
Nothing — Tab skips it. If a screen reader user reads line by line, they hear “Shipping details ▾” as plain text, with no hint it can be pressed.
function Disclosure({ title, children }) {
  const [open, setOpen] = useState(false);
  const panelId = useId();                 // unique id, safe with several instances
  return (
    <>
      <button
        type="button"
        aria-expanded={open}               // ← the state, kept in sync with React state
        aria-controls={panelId}            // ← "this button controls that element"
        onClick={() => setOpen(!open)}
      >
        {title}
      </button>
      <div id={panelId} hidden={!open}>{children}</div>
    </>
  );
}

Native HTML has a ready-made version too: <details><summary>Shipping details</summary>…</details>. It needs no JavaScript or ARIA at all. Use it when you don't need custom animation or control from code.

Live regions: announcing changes without moving focus

When something changes somewhere other than where focus is (“Added to cart”, “3 results”, “Saved”), a screen reader user won't know unless you tell them. A Live region is an element whose text changes the screen reader reads out automatically.

“Added to cart” — does anyone hear it?
Press the button a few times in each mode. The log shows what a screen reader user would get.
<div class="cart-msg">…</div>
What a screen reader user gets

Press “Add to cart”.

MarkupAnnounced…Use for
role="status" (same as aria-live="polite")when the user is idle, after the current sentence finishesconfirmations, counts, “Saved”
role="alert" (same as aria-live="assertive")right away, interruptingerrors that need attention now. Use sparingly
Practitioner§06

Keyboard and focus

In one sentenceAt any moment exactly one element has keyboard focus. Tab moves it forward through the page, in source order. Your job: every interactive thing is on that path, nothing useless is, and you can always see where focus is.

Keyboard focus is like the cursor in a text editor, but for the whole page: key presses go to whichever element has it. These are the keys every keyboard user expects to work:

KeyDoes
Tab / Shift+Tabmove to the next / previous focusable element
Enterfollow a link, press a button, submit a form
Spacepress a button, tick a checkbox (on a page with nothing focused, it scrolls)
↑ ↓ ← →move inside a widget: radio group, menu, tabs, slider (§07)
Escclose the thing that just opened: a dialog, menu or popup

What's in the tab order

The Tab order contains links that have an href, buttons, inputs, selects and textareas, unless they're disabled. Divs, spans, headings and an <a> without href are skipped. The tabindex attribute changes this:

ValueEffectUse it for
(none)the element's default: focusable if it's a controlalmost everything
tabindex="0"add to the Tab order, at its position in the sourcea custom widget that really is interactive
tabindex="-1"not in the Tab order, but element.focus() worksheadings or containers you move focus to from code (§07, §11)
tabindex="1" or higherjumps ahead of everything else on the pagenothing. Don't use it

Try them in the sandbox. The numbers are the order the browser will use:

Tab order lab
Change the settings, then click just before the sandbox and press Tab repeatedly. Badges show the order the browser will use.
Settings
Logo
Product card
Toast
Focused now: nothing in the sandbox

Default: links, inputs and buttons are Tab stops in source order. The two divs are skipped. The toast has tabindex=-1: Tab skips it, but code can still focus it — try the button above.

The order is real browser behaviour. In headless Chromium, a page with Help (tabindex=1), Promo (tabindex=3) and four normal controls was visited as Help → Promo → Logo → Search → Buy → Card. Positive values always come first, smallest number first. Then everything else follows in source order.

Never hide the focus ring (style it instead)

outline: none with nothing to replace it is one of the most common accessibility failures on the web. Without a visible focus indicator, a keyboard user is driving blind. Tick “Hide the focus ring” in the lab above and try to find where you are.

People remove it because they dislike the ring appearing after a mouse click. :focus-visible solves exactly that. The browser applies it after keyboard navigation, but not after a mouse click on a button. (Text fields still show it when clicked, because you need to see where you're typing.) Checked in Chromium: clicking a button → :focus-visible false; tabbing to it → true.

/* Remove the default ring only where the browser wouldn't show it anyway… */
button:focus:not(:focus-visible) { outline: none; }

/* …and draw a clear one for keyboard users. */
:focus-visible {
  outline: 3px solid #22d3ee;   /* ≥ 3:1 contrast against the background (§08) */
  outline-offset: 2px;          /* a gap, so it's visible on any button colour */
}

Skip links

Every page on a typical site starts with the same 20–40 header links. A keyboard user would have to Tab through all of them on every page before reaching the content. A Skip link is the first focusable thing on the page. It stays hidden until it receives focus, then jumps straight to <main>:

<body>
  <a class="skip-link" href="#main">Skip to main content</a>
  <header>… 30 links …</header>
  <main id="main" tabindex="-1">  <!-- -1: so focus can land here -->
    <h1>Checkout</h1>
  </main>
</body>

Try it on almost any government website: load a page and press Tab once. Landmarks (§03) give screen reader users a second way to skip, but sighted keyboard users have only the skip link. It's what the WCAG rule “Bypass Blocks” is about.

Practitioner§07

Managing focus: dialogs and menus

In one sentenceWhen your UI changes what's on screen (a dialog opens, a menu closes, a row is deleted), you decide where keyboard focus goes next. Otherwise it stays on something hidden, or falls back to the top of the page.

Modal dialogs

A Modal dialog asks a question and blocks everything else until you answer. For keyboard and screen reader users, “blocks everything else” has four concrete parts:

  1. Move focus in when it opens, usually to the first button, or to the safest one, like Cancel.
  2. Keep focus in while it's open. Tab must not reach the page behind it. This is a Focus trap.
  3. Escape closes it.
  4. Return focus to the button that opened it, so the user continues where they left off.

The native <dialog> element, opened with showModal(), does all four for you. Compare it with the usual hand-made overlay:

“Delete your account?” — two dialogs
Open each one with the keyboard (Tab to “Delete account…”, press Enter), then press Tab a few times and try Escape.
Account settings✕ Deliberately broken demo
Focus log

Open the dialog with the keyboard and watch where focus goes.

Here's what Chromium actually did with <dialog> + showModal() in our test:

  • Focus moved to Cancel, the first focusable element inside.
  • Tab cycled Cancel → Delete → (out to the browser's own toolbar) → Cancel. It never reached the page.
  • Escape closed it, and focus went back to the Delete account… button.
  • The rest of the page became inert. A click on a background button did nothing, and the accessibility tree contained only the dialog.
function ConfirmDelete({ open, onClose, onConfirm }) {
  const ref = useRef(null);
  const titleId = useId();

  useEffect(() => {
    const d = ref.current;
    if (open && !d.open) d.showModal();   // ← modal: page behind becomes inert
    if (!open && d.open) d.close();       //   close() puts focus back on the opener
  }, [open]);
  // Every way of closing (Cancel, Escape, the parent setting open=false) goes through
  // d.close(), which fires the "close" event once → onClose runs exactly once.

  return (
    <dialog ref={ref} aria-labelledby={titleId}
            onClose={onClose}>            {/* fires on Escape too */}
      <h2 id={titleId}>Delete your account?</h2>
      <p>This can't be undone.</p>
      <button type="button" onClick={() => ref.current.close()}>Cancel</button>
      <button type="button" onClick={onConfirm}>Delete</button>
    </dialog>
  );
}

Composite widgets: one Tab stop, arrow keys inside

Some widgets contain many items: a menu, a toolbar, tabs, a listbox. Putting every item in the Tab order would mean ten presses of Tab to get past one menu. The convention instead:

  • Tab moves into the widget and out of it. The whole group is one Tab stop.
  • Arrow keys move between items inside it. Home and End jump to the first and last item.

The usual way to build this is a Roving tabindex. Exactly one item has tabindex="0" (so Tab lands there), and every other item has tabindex="-1". When an arrow key is pressed, you move the 0 to the next item and call .focus() on it. Try the menu below and watch the attributes change:

A menu button you can drive with the keyboard
Tab to “Actions”, press Enter or ↓, then use ↑ ↓ Home End, a letter, Enter or Escape. The table shows the live tabindex values.

Last action: none yet

Live attributes
Current tabindex of each menu item
elementattribute
button “Actions”aria-expanded=false
menuitem “Edit”tabindex=0
menuitem “Duplicate”tabindex=-1
menuitem “Archive”tabindex=-1
menuitem “Delete”tabindex=-1
Key log

Nothing yet.

The keys and roles above aren't made up. They come from the ARIA Authoring Practices Guide, the W3C's catalogue of widget patterns. Before building any custom widget (tabs, combobox, tree view, slider), look up its pattern there. It tells you every key and every ARIA attribute users will expect.

// The core of roving tabindex (simplified from the widget above)
const [active, setActive] = useState(0);
const items = useRef([]);          // the <li> DOM elements

useEffect(() => { items.current[active]?.focus(); }, [active]); // (the real one only while open)

function onKeyDown(e) {
  const last = ACTIONS.length - 1;
  if (e.key === "ArrowDown")    setActive((i) => (i === last ? 0 : i + 1)); // wrap around
  else if (e.key === "ArrowUp") setActive((i) => (i === 0 ? last : i - 1));
  else if (e.key === "Home")    setActive(0);
  else if (e.key === "End")     setActive(last);
  else return;                  // any other key: leave it alone
  e.preventDefault();           // ← handled: stop the arrows/Home/End also scrolling the page
}

<ul role="menu" onKeyDown={onKeyDown}>
  {ACTIONS.map((a, i) => (
    <li key={a} role="menuitem"
        ref={(el) => (items.current[i] = el)}
        tabIndex={i === active ? 0 : -1}>   {/* ← the roving part */}
      {a}
    </li>
  ))}
</ul>
Practitioner§08

Seeing it: contrast, colour, zoom, motion

In one sentenceText must stand out from its background by a measurable amount. Information must never live in colour alone. Pages must survive zoom. Motion must respect the user's “reduce motion” setting.

Contrast is a number, not a matter of taste

WCAG defines a Contrast ratio between two colours. It's computed from each colour's Relative luminance: how bright the eye perceives it, from 0 for black to 1 for white. The recipe:

  1. Turn each red, green and blue value (0–255) into a 0–1 number, then undo the screen's “gamma” curve. Values ≤ 0.04045 are divided by 12.92; larger ones become ((c + 0.055) / 1.055) ^ 2.4. You don't need to know why: this just converts the stored number into actual light.
  2. Luminance L = 0.2126·R + 0.7152·G + 0.0722·B. Green counts most because our eyes are most sensitive to it.
  3. Ratio = (L_lighter + 0.05) / (L_darker + 0.05). The result runs from 1:1 (same colour) to 21:1 (black on white).

Worked example: #999999 grey on white. 153/255 = 0.6 → about 0.319 after step 1, and since R = G = B, L ≈ 0.319. White has L = 1. Ratio = (1 + 0.05) / (0.319 + 0.05) ≈ 2.85:1, nowhere near the 4.5 needed. (axe displays it as 2.84 because it cuts the number off at two decimals, and the checker below does the same. The pass/fail test itself uses the exact value, and WCAG allows no rounding up: a ratio of 4.499 fails.)

Contrast checker
Pick two colours or try a preset. The maths is the WCAG 2 formula, shown live.
Hex, like #1e293b
Hex, like #1e293b

Normal text: we'll send your receipt here.

Large text: Pay $42

Contrast ratio 4.54 : 1

L(text) = 0.1812 · L(bg) = 1.0000
ratio = (lighter + 0.05) / (darker + 0.05) = (1.0000 + 0.05) / (0.1812 + 0.05)

WCAG thresholds and results
UseNeedsResult
Normal text · AA4.5:1✓ Pass
Large text (24px, or 18.7px bold) · AA3:1✓ Pass
Normal text · AAA7:1✕ Fail
Large text · AAA4.5:1✓ Pass
Icons, input borders, focus rings · AA3:1✓ Pass

The thresholds (WCAG 2.2, level AA):

WhatMinimum
Normal text4.5 : 1
Large text: 24px, or 18.66px (14pt) bold3 : 1
UI parts you need to see to use them: input borders, icons, focus rings3 : 1
Disabled controls, logos, pure decorationno requirement

Don't rely on colour alone

About 1 in 12 men and 1 in 200 women have some form of colour-vision deficiency, most often trouble telling red from green. Some users override all page colours with a high-contrast theme, and printouts are often greyscale. So colour can reinforce a message, but never be the only way to get it.

The two lists below use a red and a green that look almost the same to someone with deuteranopia, a strong form of red-green colour blindness. The simulation uses a standard published colour model, so treat it as an approximation:

Which builds failed?
Answer for the left list, then switch the view. The deuteranopia view is a standard simulation of red-green colour blindness; real experiences vary.
✕ colour only
  • api
  • web
  • worker
  • docs
✓ colour + icon + text
  • api ✓ passed
  • web ✕ failed
  • worker ✓ passed
  • docs ✕ failed

Easy with typical colour vision: red means failed. Now switch the view.

Common fixes: an icon plus text (“✕ failed”), underlined links inside paragraphs (not just a different colour), and error messages in words, not only a red border (§09). For charts, use patterns or direct labels, not just a colour legend.

Zoom and reflow

Low-vision users zoom. WCAG asks for two things:

  • Text can be resized to 200% without anything getting cut off or overlapping.
  • Reflow: at 320 CSS px wide, about 400% zoom on a 1280 px screen, content fits in one column with no sideways scrolling.

The habits that get you there, mostly covered in CSS layout:

  • Size text in rem, not px. rem follows the user's browser font-size setting.
  • Never set maximum-scale=1 or user-scalable=no in the Viewport meta tag. That blocks pinch-zoom on phones.
  • Let containers grow with their text: avoid fixed height on things that contain text.
  • Test by zooming your own app to 400%.

Respect “reduce motion”

Users who get dizzy from motion (§02) can turn on “Reduce motion” in their OS settings. Browsers expose it to CSS as the Media query prefers-reduced-motion:

.card { transition: transform 300ms ease; }
.card:hover { transform: scale(1.05); }

@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }          /* ← no zoom animation */
  html  { scroll-behavior: auto; }     /* ← no smooth-scrolling jumps */
}

“Reduce” doesn't have to mean “remove”. A quick fade is usually fine. What triggers symptoms is large movement: zooms, parallax, spinning, full-screen slides. Also, anything that moves by itself for more than 5 seconds (a carousel, an animated banner) needs a pause button, whatever the user's settings say.

Practitioner§09

Forms and error messages

In one sentenceA good form says what each field is (label), how to fill it in (hint), and, when something goes wrong, which field, what's wrong and how to fix it, in words, where the user's focus is.

Forms are where accessibility failures cost real money: sign-ups, checkouts, bookings. They combine everything so far: names (§04), descriptions, states (§05) and focus management (§07). Try both versions with the keyboard and watch the screen reader preview:

Sign-up form: what happens on a mistake?
Leave both fields empty (or type a bad email) and press Create account — with the keyboard, in each version.
✕ Deliberately broken demo
Screen reader preview (on focus)

Focus a field to hear it.

Generated live from the DOM: name, role, states, description. A rough imitation of NVDA; real wording varies by screen reader.

On submit

Press Create account.

In the typical version, a screen reader user hears nothing at all after pressing Create account. Focus stays on the button, and the red borders are invisible to them. In the accessible version, focus moves to the first broken field, and its name, “invalid” state and error message are read in one go.

The anatomy of an accessible field

const id = useId();                          // unique per form instance (§05)
const hintId = `${id}-hint`, errId = `${id}-err`;

<form noValidate onSubmit={handleSubmit}>    {/* ← turn off the browser's own error bubbles */}
  <label htmlFor={id}>Email</label>
  <p id={hintId}>We'll send your receipt here.</p>
  <input
    id={id}
    type="email"
    autoComplete="email"                     // ← browsers & password managers can fill it
    required                                 // ← still announced as "required"
    aria-invalid={Boolean(error)}            // ← announced as "invalid entry"
    aria-describedby={error ? `${hintId} ${errId}` : hintId}  // ← hint + error read after the name
  />
  {/* ← the error as text, not just a red border: */}
  {error && <p id={errId}>✕ {error}</p>}
  <button type="submit">Create account</button>
</form>

What each piece does for whom:

PieceWho it helps
Visible <label> (not a placeholder)everyone, especially after typing, when a placeholder has disappeared; voice-control users say the label to focus the field
autocomplete="email" / "new-password"people with motor or memory impairments: less typing, and password managers work
requiredscreen reader users hear “required” before they start
aria-invalid="true"screen reader users hear “invalid entry”
aria-describedby → hint and errorscreen reader users hear the hint and the fix together with the field
Error text with an icon, not just colourcolour-blind users, everyone else (§08)

Where to put focus after a failed submit

Two common patterns, both fine:

  1. Focus the first invalid field. Its name, “invalid” and its error (through aria-describedby) are read straight away. Simple, good for short forms. This is what the demo does.
  2. An error summary at the top for long forms: a heading like “There are 3 problems”, with a list of links that each jump to a broken field. Move focus to the summary, using tabindex="-1" on its container and .focus(). GOV.UK's design system made this pattern famous.

Groups of controls: fieldset and legend

Radio buttons and checkbox groups need a name for the group as well as for each option. Otherwise a screen reader user hears “Express, radio button, 1 of 2” without the question they're answering:

<fieldset>
  <legend>Delivery speed</legend>        <!-- the group's name -->
  <label><input type="radio" name="speed" value="std"> Standard (3–5 days)</label>
  <label><input type="radio" name="speed" value="exp"> Express (next day)</label>
</fieldset>
<!-- NVDA (roughly): "Delivery speed, grouping, Standard (3–5 days), radio button, not checked, 1 of 2" -->

Native radio groups also come with arrow-key navigation built in. That's the roving-tabindex behaviour from §07, with no code to write.

Practitioner§10

Testing: axe, keyboard, screen readers

In one sentenceAutomated checkers find the mechanical problems fast (a missing label, bad contrast). Only a human with a keyboard and a screen reader finds out whether the page actually works. You need both.

Automated: axe-core

axe-core is the engine behind most automated accessibility checks. That includes the accessibility score in Lighthouse (Chrome DevTools → Lighthouse), the axe DevTools browser extension, and test helpers for Playwright, Cypress and Jest. It loads into the page, inspects the live DOM, the computed styles and the accessibility tree, and returns a list of rule violations.

We ran it for real on three small pages. Compare what it reported with what it missed:

What axe finds — and what it doesn't
Real axe-core 4.14.0 output, run in headless Chromium on three small pages.
<html>
<div class="header">
  <img src="logo.png">
  <div onclick="go('/cart')">Cart</div>
</div>
<div class="title">Checkout</div>
<input type="email" placeholder="Email">
<!-- .hint is #999 on white: -->
<p class="hint">We'll send your receipt here.</p>
<button class="icon"><svg>…</svg></button>
<div class="btn" role="button" tabindex="0"
     onclick="pay()">Pay $42</div>
<div aria-hidden="true">
  <a href="/terms">Terms</a>
</div>
<h3>Order summary</h3>
<ul><div>1 × Headphones</div></ul>
axe.run() → 9 violations
  • criticalbutton-nameWCAG 4.1.2
    Buttons must have discernible text
    button.icon — Element does not have inner text that is visible to screen readers; aria-label attribute does not exist or is empty; … no title attribute
  • criticalimage-altWCAG 1.1.1
    Images must have alternative text
    img — Element does not have an alt attribute; …
  • seriousaria-hidden-focusWCAG 4.1.2
    ARIA hidden element must not be focusable or contain focusable elements
    div[aria-hidden="true"] — Focusable content should have tabindex="-1", be removed from the DOM, or not be hidden with aria-hidden
  • seriouscolor-contrastWCAG 1.4.3
    Elements must meet minimum color contrast ratio thresholds
    p.hint — Element has insufficient color contrast of 2.84 (foreground color: #999999, background color: #ffffff, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1
  • serioushtml-has-langWCAG 3.1.1
    <html> element must have a lang attribute
    html — The <html> element does not have a lang attribute
  • seriouslistWCAG 1.3.1
    <ul> and <ol> must only directly contain <li>, <script> or <template> elements
    ul — List element has direct children that are not allowed: div
  • moderatelandmark-one-mainbest practice
    Document should have one main landmark
    html — Document does not have a main landmark
  • moderatepage-has-heading-onebest practice
    Page should contain a level-one heading
    html — Page must have a level-one heading
  • moderateregionbest practice
    All page content should be contained by landmarks
    .header, .title, input, p, h3, ul — Some page content is not contained by landmarks
Real problems axe did not report
  • “Cart” is a div with a click handler: no keyboard access, no link role. Not reported.
  • “Pay $42” has role=button and tabindex=0 but no key handler, so Enter and Space do nothing. Not reported.
  • The email field's only label is its placeholder. axe accepts a placeholder as a name, so no “label” violation.
  • “Checkout” looks like a heading but is a div. Only hinted at by the best-practice heading rule.

Look at the “did not report” box for the div soup. The page can't be used with a keyboard at all, and the only WCAG failure axe reports is a missing label. axe can't know that a div with a click handler was meant to be a button. Deque, axe's maker, estimates that automated tests find roughly half of all accessibility issues (their study of real audits put it at 57% by issue count). Other estimates are lower. Either way, a clean report is where testing starts, not where it ends.

Put it in your test suite

The cheapest time to catch a regression is in CI, before merge. With Playwright and the @axe-core/playwright package:

checkout.a11y.spec.js
import { test, expect } from "@playwright/test";
import { AxeBuilder } from "@axe-core/playwright";

test("checkout has no detectable WCAG A/AA violations", async ({ page }) => {
  await page.goto("/checkout/");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])  // ← add "wcag22aa" for the 2.2 rules
    .analyze();
  expect(results.violations).toEqual([]);
});

test("Pay button is reachable with Tab and works with Enter", async ({ page }) => {
  await page.goto("/checkout/");
  const pay = page.getByRole("button", { name: "Pay $42" });   // ← finds it by role + name
  const isFocused = () => pay.evaluate((el) => el === document.activeElement);
  for (let i = 0; i < 20 && !(await isFocused()); i++) {
    await page.keyboard.press("Tab");                          // ← reach it the way users do
  }
  await expect(pay).toBeFocused();
  await page.keyboard.press("Enter");
  await expect(page.getByRole("heading", { name: "Thank you" })).toBeVisible();
});

Why press Tab instead of calling pay.focus()? Because .focus() from code also works on an element with tabindex="-1", which Tab can never reach (§06). A test that calls .focus() would pass for exactly the bug it should catch.

We ran this file for real (Playwright 1.63, headless Chromium) against two tiny pages. Against the semantic checkout from §03, with a submit handler that shows “Thank you”:

Running 2 tests using 1 worker

  ✓  1 tests/checkout.a11y.spec.js:4:5 › checkout has no detectable WCAG A/AA violations (625ms)
  ✓  2 tests/checkout.a11y.spec.js:12:5 › Pay button is reachable with Tab and works with Enter (285ms)

  2 passed (1.8s)

Against a broken version whose Pay “button” is <div role="button" tabindex="-1"> and whose email input has no label, both tests fail. The second failure, trimmed:

  ✘  1 tests/checkout.a11y.spec.js:12:5 › Pay button is reachable with Tab and works with Enter (2.4s)

    Error: expect(locator).toBeFocused() failed
    Locator:  getByRole('button', { name: 'Pay $42' })
    Expected: focused
    Received: inactive
        21 × locator resolved to <div class="btn" role="button" tabindex="-1" onclick="…">Pay $42</div>
           - unexpected value "inactive"

The axe test failed on the same page with rule label (critical, “Form elements must have labels”).

getByRole(…) is the best habit in that test. It finds elements the way a screen reader does, by role and accessible name, so the test fails if someone swaps the button for a div. Testing Library (screen.getByRole("button", { name: /pay/i })) works the same way in unit tests. If a test can't find your control by role and name, a screen reader user can't either.

Manual: the keyboard walkthrough (10 minutes, every feature)

Put the mouse away and go through the feature:

  1. Tab from the address bar. Is there a skip link? Does focus go through the page in a sensible order?
  2. Can you always see focus? Watch for it disappearing behind sticky headers or on dark backgrounds.
  3. Can you operate everything? Buttons with Enter and Space, menus with arrows, dialogs with Escape.
  4. Do you ever get stuck, or lose focus to the top of the page after an action?
  5. After a dialog closes or an item is deleted, is focus somewhere sensible?
  6. Zoom to 200% and 400%. Does anything overlap, get cut off, or need sideways scrolling?

Manual: screen reader basics

You don't need to be an expert. Learn a handful of commands on the screen reader you have, and listen to your feature once:

Screen readerStart / stopRead next itemUseful lists
VoiceOver (macOS, built in)⌘+F5VO+→ (VO = Ctrl+Option)VO+U opens the rotor: headings, links, landmarks, form controls
NVDA (Windows, free)Ctrl+Alt+N / NVDA+Q↓H next heading, D next landmark, F next form field, NVDA+F7 elements list
VoiceOver (iOS)Settings → Accessibility, or triple-click the side button if set as the shortcutswipe rightrotor: twist two fingers
TalkBack (Android)Settings → Accessibility, or hold both volume keys if set as the shortcutswipe rightreading controls menu

The NVDA key is Insert by default. You can also set Caps Lock as the NVDA key, which is popular on laptops. What to listen for: does every control say a sensible name, role and state? Do the headings list and landmarks list make sense on their own? Are changes like “Added to cart” or errors announced?

Staff-level§11

Staff level: WCAG, the law, component libraries, React

In one sentenceAt scale, accessibility is won or lost in shared components and process, not in individual screens. Fix the Button once and 400 screens get better. Leave it broken and every team has to rediscover the bug.

WCAG 2.2 AA, in practice

WCAG is organised in layers. Four principles, POUR. Under them, guidelines. Under those, testable success criteria (SC), each with a number and a level: A (the bare minimum), AA (the usual target in contracts and laws), and AAA (aspirational, not required across whole sites). Version 2.2 (October 2023) added nine criteria and removed one: 4.1.1 Parsing, which modern browsers made obsolete.

WCAG 2.2, as a map
A selection of success criteria grouped by principle. Filter to see the usual legal target (A + AA) or what's new in 2.2.

24 criteria shown (out of 86 in WCAG 2.2).

Perceivable
  • 1.1.1Non-text ContentA
    Images and icons have a text alternative (alt, aria-label).
  • 1.3.1Info and RelationshipsA
    Structure you can see (headings, lists, labels, tables) is in the markup too.
  • 1.4.1Use of ColorA
    Colour is never the only way information is shown.
  • 1.4.3Contrast (Minimum)AA
    Text contrast at least 4.5:1 (3:1 for large text).
  • 1.4.4Resize TextAA
    Text can be zoomed to 200% without losing content.
  • 1.4.10ReflowAAsince 2.1
    Works at 320 CSS px wide without sideways scrolling.
  • 1.4.11Non-text ContrastAAsince 2.1
    Input borders, icons and focus rings reach 3:1.
Operable
  • 2.1.1KeyboardA
    Everything works with a keyboard alone.
  • 2.1.2No Keyboard TrapA
    Focus can always move away again (a dialog's focus loop is fine because Escape/Close exits it).
  • 2.4.1Bypass BlocksA
    A way to skip repeated blocks — a skip link or landmarks.
  • 2.4.3Focus OrderA
    Tab order makes sense.
  • 2.4.7Focus VisibleAA
    You can see where keyboard focus is.
  • 2.4.11Focus Not Obscured (Minimum)AAsince 2.2
    The focused element isn't completely hidden by sticky headers or banners.
  • 2.5.3Label in NameAsince 2.1
    The accessible name contains the visible label text.
  • 2.5.7Dragging MovementsAAsince 2.2
    Anything done by dragging can also be done with simple clicks.
  • 2.5.8Target Size (Minimum)AAsince 2.2
    Click/tap targets are at least 24×24 CSS px, or spaced out enough.
Understandable
  • 3.1.1Language of PageA
    <html lang="…"> is set, so screen readers use the right voice.
  • 3.2.6Consistent HelpAsince 2.2
    Help links/contact details appear in the same place on every page.
  • 3.3.1Error IdentificationA
    Errors are described in text, not just a red border.
  • 3.3.2Labels or InstructionsA
    Inputs have labels or instructions.
  • 3.3.7Redundant EntryAsince 2.2
    Don't make people type the same information twice in one process.
  • 3.3.8Accessible Authentication (Minimum)AAsince 2.2
    Logging in doesn't require a memory or puzzle test; allow paste and password managers.
Robust
  • 4.1.2Name, Role, ValueA
    Every control exposes a name, a role and its current state.
  • 4.1.3Status MessagesAAsince 2.1
    Status messages are announced without moving focus (live regions).

Most of what this page taught maps onto a handful of these: 1.1.1, 1.3.1, 1.4.3, 2.1.1, 2.4.3, 2.4.7, 3.3.1 and 4.1.2 account for the bulk of real-world failures. In WebAIM's yearly scan of the top million home pages, low-contrast text, missing image alt text, unlabelled form inputs, empty links and empty buttons are the most common detected errors, year after year.

The law (a general picture, not legal advice)

Laws differ by country and change often, so treat this as orientation and ask your legal team for specifics. The pattern is similar almost everywhere: WCAG level AA is the technical yardstick.

  • United States. Websites of businesses open to the public have been the subject of thousands of lawsuits a year under the Americans with Disabilities Act (ADA). Courts and settlements usually point at WCAG 2.x AA. Federal agencies and their suppliers must meet Section 508, which incorporates WCAG 2.0 AA. A 2024 Department of Justice rule set WCAG 2.1 AA for state and local government websites, with compliance dates originally set for April 2026 and April 2027 depending on the size of the government. Check the current status, since dates like these can be changed.
  • European Union. Public-sector sites have had requirements since the Web Accessibility Directive (2016). The European Accessibility Act has applied since 28 June 2025 to many private-sector products and services, including e-commerce, banking, e-books and passenger transport. Its technical standard, EN 301 549, builds on WCAG 2.1 AA.
  • Elsewhere. The UK (Equality Act and public-sector regulations), Canada, Australia, Japan and others have similar rules, mostly pointing at WCAG.

Enterprise and government buyers often ask for an accessibility conformance report, usually written in the VPAT format. It's a criterion-by-criterion statement of how your product meets WCAG. Writing an honest one is far easier if you've been testing all along.

Building an accessible component library

A Component library is where accessibility scales. What the good ones do:

  1. Semantics inside, no escape hatch needed. <Button> renders a <button>. <Link> renders an <a href>. A <Dialog> uses <dialog> or implements every part of the APG dialog pattern. Product teams should never need div onClick.
  2. Make the accessible path the easy path. Make the name required: <IconButton icon={TrashIcon} label="Delete invoice" /> should fail type-checking (or at least warn) without label. Have <TextField label="Email" error={msg} /> wire up id, htmlFor, aria-describedby and aria-invalid internally, so nobody can forget them.
  3. Document the keyboard contract for each component: “Tabs: ←/→ move, Home/End jump, Tab leaves”. Test it with Playwright, like §10.
  4. Contrast in the tokens. If every text and background token pair is checked once, screens can't drift (§08).
  5. Or build on a headless library that already implements the APG patterns and leaves the styling to you: React Aria, Radix UI, Headless UI, Ariakit. Writing a correct combobox from scratch takes weeks. Reusing one takes an afternoon.

React specifics

React apps are usually single-page apps, and that breaks one thing the browser normally does for free. On a classic page load, the screen reader announces the new page title and focus starts at the top. On a client-side route change, the URL and the content change, but focus stays on the link you clicked, which may not even exist any more. Nothing is announced.

The common fix is to move focus to the new page's main heading after each navigation:

RouteFocus.jsx
import { useEffect, useRef } from "react";
import { useLocation } from "react-router";

// Render once, in the layout that wraps every page.
export function RouteFocus() {
  const { pathname } = useLocation();
  const prev = useRef(pathname);

  useEffect(() => {
    if (prev.current === pathname) return;  // first load (and StrictMode's re-run): leave focus alone
    prev.current = pathname;
    const h1 = document.querySelector("main h1");
    if (h1) {
      h1.tabIndex = -1;                     // ← focusable from code, not a Tab stop (§06)
      h1.focus();                           // ← screen reader reads the new page's heading
    }
  }, [pathname]);

  return null;
}

Also update the page title on every route. React 19 lets any component render <title>Checkout · ShopCo</title>, and React moves it into <head> for you. Some teams prefer a polite live region (§05) that says “Navigated to Checkout” instead of moving focus. Either is fine, as long as something tells the user the page changed.

Two more React habits:

  • Fragments keep the structure valid. A <ListItem> component that returns <div><li>…</li></div> breaks the list. axe's list rule in §10 is exactly that bug. Return <li> directly, or a fragment <>…</> when you need several siblings. The same goes for table rows and <dl> pairs. See React's model.
  • useId() for every id/ARIA pairing (§05), never hard-coded ids. Two copies of a component with id="email" give both labels the same target, and one field ends up without a name.
Practitioner§12

Pitfalls gallery

Nine mistakes that show up in almost every audit, each with the fix.

#1

The clickable div

No Tab stop, no Enter or Space, no role. Adding role="button" tabIndex={0} only fixes the announcement and the Tab stop. You'd still need key handlers, disabled handling and form submission. Use the element (§01, §03).

✕ Don't
<div className="btn" onClick={save}>Save</div>
✓ Do
<button type="button" className="btn" onClick={save}>
  Save
</button>
#2

Icon-only button with no name

The browser computes no name for a button containing only an <svg> (§04). A screen reader says “button”. Voice-control users have nothing to say. axe reports button-name (critical).

✕ Don't
<button onClick={remove}>
  <TrashIcon />
</button>
✓ Do
<button onClick={remove} aria-label="Delete invoice">
  <TrashIcon aria-hidden="true" />
</button>
#3

outline: none with no replacement

This is usually a global CSS reset added because the focus ring “looks ugly” after mouse clicks. Keyboard users then can't see where they are. Use :focus-visible (§06).

✕ Don't
*:focus { outline: none; }
✓ Do
:focus-visible {
  outline: 3px solid #22d3ee;
  outline-offset: 2px;
}
#4

aria-hidden on something focusable

It's common on off-screen drawers and on a “hidden” page behind a modal. Tab still lands on the links inside, but the screen reader can't describe them. Use inert (or hidden), which removes them from the keyboard and the accessibility tree.

✕ Don't
<aside class="drawer closed" aria-hidden="true">
  <a href="/settings">Settings</a>
</aside>
✓ Do
<aside class="drawer closed" inert>
  <a href="/settings">Settings</a>
</aside>
#5

Placeholder instead of a label

It disappears as soon as you type, it's usually low-contrast grey, and some older assistive tech ignores it. axe won't complain, because a placeholder does count as a name (§10). Keep a visible label. A placeholder is only for an example value.

✕ Don't
<input type="email" placeholder="Email">
✓ Do
<label for="email">Email</label>
<input id="email" type="email" placeholder="name@example.com">
#6

Positive tabindex to “fix” the order

tabindex="1" moves an element ahead of everything on the page, including the header (§06). Then someone adds tabindex="2" somewhere else, and so on. Fix the source order instead, and use only 0 and -1.

#7

Hover-only menus and tooltips

:hover doesn't exist for keyboard or touch users. A menu that opens on hover must also open on click or Enter (with aria-expanded, §05). A tooltip must also appear on focus and close with Escape. Better still, put important information in visible text.

#8

Alt text that's missing, or says too much

Every <img> needs an alt. If it carries information, describe the information: alt="Revenue up 40% in Q3", not alt="chart". If it's decoration, use alt="" (empty, not missing), and screen readers skip it. Never alt="image of…": the reader already says “image”.

✕ Don't
<img src="q3.png">
<img src="divider.png" alt="divider image">
✓ Do
<img src="q3.png" alt="Revenue up 40% in Q3">
<img src="divider.png" alt="">
#9

The vanishing toast

“Saved!” fades in for 2 seconds in a corner. It isn't in a live region, so nobody hears it (§05). It also vanishes before slow readers or magnifier users find it. Put it in a role="status" region that already exists on the page, and keep important messages on screen until dismissed (or for at least ~5 seconds, pausing while hovered or focused).

Practitioner§13

Check yourself

Commit to an answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
The clickable div

A keyboard-only user Tabs through the checkout page. What happens at this element?

<div className="btn" onClick={pay}>Pay $42</div>
QUESTION 2 / 7
What's the accessible name?
<button aria-label="Close dialog">X</button>
QUESTION 3 / 7
Tab order

Focus starts at the top of the page. In what order does Tab visit them?

<button tabindex="2">A</button>
<button>B</button>
<button tabindex="1">C</button>
<div tabindex="0">D</div>
<div tabindex="-1">E</div>
QUESTION 4 / 7
Is this grey OK?

Body text is #767676 on a white background, 16px, normal weight. The contrast ratio is about 4.54:1. Under WCAG 2.2 AA…

QUESTION 5 / 7
The toast that nobody heard

After saving, this renders the status div together with its text. A screen reader user is on the Save button. What do they most likely hear?

{saved && <div role="status">Saved!</div>}
QUESTION 6 / 7
aria-hidden on a container
<div aria-hidden="true">
  <button>Buy now</button>
</div>
QUESTION 7 / 7
The half-fixed div

It now has a role and a Tab stop. What's still missing compared with a real button?

<span role="button" tabindex="0" onclick="save()">Save</span>
Answered 0 / 7
Foundations§14

Cheat sheet

🧱
Real elements firstbutton for actions, a href for navigation, label for inputs, h1–h6 in order, header/nav/main/footer for landmarks.
🏷️
Name, role, stateEvery control exposes all three. Name priority: aria-labelledby → aria-label → label/content → title → placeholder.
🩹
ARIA adds meaning, never behaviourNo ARIA is better than bad ARIA. Use it for states HTML can't express: aria-expanded, aria-invalid, live regions.
⌨️
Keyboard parityTab and Shift+Tab move between controls, Enter/Space press them, arrows move inside widgets, Escape closes. Source order is focus order.
🎯
Visible focus, alwaysStyle :focus-visible with at least 3:1 contrast. Never a bare outline: none.
🪟
Dialogs: in, contained, Escape, back<dialog> + showModal() does all four. Move focus yourself after route changes and deletions.
🌗
Contrast and colour4.5:1 for text, 3:1 for large text and UI parts. Colour is never the only signal.
🧪
Test like users doaxe in CI, getByRole in tests, a 10-minute keyboard walkthrough, and a screen reader on key flows.
You need…UseNot
an action<button type="button"><div onClick>, <a href="#">
navigation<a href="/x"><button onClick={navigate}>
a name for an icon buttonaria-label="Delete invoice" + aria-hidden on the iconnothing, or title alone
a field label<label for> / htmlForplaceholder alone
help or error text on a fieldaria-describedby="hint err" + aria-invalidred border only
show / hide toggle<button aria-expanded> or <details>a clickable div
an announcementan existing role="status" (polite) or role="alert" (urgent)a toast that's inserted, then fades out
a modal<dialog> + showModal()a div overlay
arrow-key navigation in a grouproving tabindex (0 on one item, -1 on the rest)every item tabindex="0"
to hide from everyonehidden / inertaria-hidden on focusable content
motion@media (prefers-reduced-motion: reduce)autoplay with no pause
a quick auditChrome DevTools → Accessibility pane, Lighthouse, axean overlay script
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Frontend & BrowserHow React Works
Read →