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.
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.
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:
Nothing yet. Press “Start here”, then Tab.
| Version | Mouse | Tab | Enter/Space | Screen 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.
Use the real element
<button>, <a href>, <label>, <h1>, <nav>. They come with keyboard support and meaning built in (§03).Name, role, state
Keyboard and focus
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
Screen reader
Zoom and magnification
Voice control
Motion sensitivity
Cognitive load
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.
Semantic HTML first
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:
<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>- text: ShopCo Cart Help Checkout Email
- textbox (no name!)
- text: Save my details Pay $42
- 1. textbox (unnamed)
- 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… | Use | Keyboard 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 |
<!-- 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><!-- 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):
| Element | Landmark role | Typical content |
|---|---|---|
<header> (top level) | banner | logo, site-wide search |
<nav> | navigation | menus. With more than one, label each: aria-label="Main" |
<main> | main | the page's unique content. Exactly one per page |
<aside> | complementary | related links, sidebars |
<footer> (top level) | contentinfo | copyright, legal links |
<search> | search | a 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>The accessibility tree: name, role, state
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:
| Property | Question | Examples |
|---|---|---|
| Role | What kind of thing is it? | button, link, checkbox, heading, navigation, dialog |
| Accessible name | What's it called? | “Pay $42”, “Email”, “Main” |
| State | What's going on with it right now? | checked, expanded, disabled, invalid, pressed |
| Value | What's in it? | the text typed into a field, a slider's number |
Step through how one checkbox travels from HTML to speech:
1<label>2 <input type="checkbox" checked>3 Email me order updates4</label>
label element holding an input and some text. So far this is the same tree the browser uses to draw the page.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:
aria-labelledby="id1 id2": joins the text of the elements with those ids.aria-label="…": a string you write that is never shown on screen.- A
<label>, connected withfor/idor by wrapping the input. - The
titleattribute, which is also a mouse-hover tooltip. - 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:
<h2 id="h">Find products</h2>
<label for="q">Search</label>
<input id="q" type="search"
placeholder="e.g. headphones">Find products
A few results worth remembering (each one checked in headless Chromium):
| Markup | Name 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." -->ARIA: powerful, and easy to make worse
div with role="button" is announced as a button, but it still ignores Enter and Space.ARIA attributes come in three kinds:
| Kind | What it says | Examples |
|---|---|---|
| Roles | what this element is | role="dialog", role="tab", role="status" |
| States | what's true right now, changing as you use it | aria-expanded="true", aria-pressed, aria-checked, aria-invalid, aria-disabled |
| Properties | relationships and labels, which rarely change | aria-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:
| Markup | What 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 links | Screen 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”.
<div class="toggle" onclick="toggle()">
Shipping details ▾
</div>
<div class="panel" style="display:none">
Ships in 2 working days.
</div>- role
- none (generic text)
- name
- none
- expanded
- unknown
- focusable
- no
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.
<div class="cart-msg">…</div>Press “Add to cart”.
| Markup | Announced… | Use for |
|---|---|---|
role="status" (same as aria-live="polite") | when the user is idle, after the current sentence finishes | confirmations, counts, “Saved” |
role="alert" (same as aria-live="assertive") | right away, interrupting | errors that need attention now. Use sparingly |
Keyboard and focus
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:
| Key | Does |
|---|---|
| Tab / Shift+Tab | move to the next / previous focusable element |
| Enter | follow a link, press a button, submit a form |
| Space | press a button, tick a checkbox (on a page with nothing focused, it scrolls) |
| ↑ ↓ ← → | move inside a widget: radio group, menu, tabs, slider (§07) |
| Esc | close 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:
| Value | Effect | Use it for |
|---|---|---|
| (none) | the element's default: focusable if it's a control | almost everything |
tabindex="0" | add to the Tab order, at its position in the source | a custom widget that really is interactive |
tabindex="-1" | not in the Tab order, but element.focus() works | headings or containers you move focus to from code (§07, §11) |
tabindex="1" or higher | jumps ahead of everything else on the page | nothing. Don't use it |
Try them in the sandbox. The numbers are the order the browser will use:
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.
Managing focus: dialogs and menus
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:
- Move focus in when it opens, usually to the first button, or to the safest one, like Cancel.
- Keep focus in while it's open. Tab must not reach the page behind it. This is a Focus trap.
- Escape closes it.
- 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:
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:
Last action: none yet
| element | attribute |
|---|---|
| button “Actions” | aria-expanded=false |
| menuitem “Edit” | tabindex=0 |
| menuitem “Duplicate” | tabindex=-1 |
| menuitem “Archive” | tabindex=-1 |
| menuitem “Delete” | tabindex=-1 |
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>Seeing it: contrast, colour, zoom, motion
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:
- 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. - Luminance
L = 0.2126·R + 0.7152·G + 0.0722·B. Green counts most because our eyes are most sensitive to it. - 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.)
Normal text: we'll send your receipt here.
Large text: Pay $42
L(text) = 0.1812 · L(bg) = 1.0000
ratio = (lighter + 0.05) / (darker + 0.05) = (1.0000 + 0.05) / (0.1812 + 0.05)
| Use | Needs | Result |
|---|---|---|
| Normal text · AA | 4.5:1 | ✓ Pass |
| Large text (24px, or 18.7px bold) · AA | 3:1 | ✓ Pass |
| Normal text · AAA | 7:1 | ✕ Fail |
| Large text · AAA | 4.5:1 | ✓ Pass |
| Icons, input borders, focus rings · AA | 3:1 | ✓ Pass |
The thresholds (WCAG 2.2, level AA):
| What | Minimum |
|---|---|
| Normal text | 4.5 : 1 |
| Large text: 24px, or 18.66px (14pt) bold | 3 : 1 |
| UI parts you need to see to use them: input borders, icons, focus rings | 3 : 1 |
| Disabled controls, logos, pure decoration | no 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:
- api
- web
- worker
- docs
- 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, notpx.remfollows the user's browser font-size setting. - Never set
maximum-scale=1oruser-scalable=noin the Viewport meta tag. That blocks pinch-zoom on phones. - Let containers grow with their text: avoid fixed
heighton 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.
Forms and error messages
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:
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.
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:
| Piece | Who 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 |
required | screen reader users hear “required” before they start |
aria-invalid="true" | screen reader users hear “invalid entry” |
aria-describedby → hint and error | screen reader users hear the hint and the fix together with the field |
| Error text with an icon, not just colour | colour-blind users, everyone else (§08) |
Where to put focus after a failed submit
Two common patterns, both fine:
- 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. - 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.
Testing: axe, keyboard, screen readers
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:
<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>- critical
button-nameWCAG 4.1.2Buttons must have discernible textbutton.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 - critical
image-altWCAG 1.1.1Images must have alternative textimg — Element does not have an alt attribute; … - serious
aria-hidden-focusWCAG 4.1.2ARIA hidden element must not be focusable or contain focusable elementsdiv[aria-hidden="true"] — Focusable content should have tabindex="-1", be removed from the DOM, or not be hidden with aria-hidden - serious
color-contrastWCAG 1.4.3Elements must meet minimum color contrast ratio thresholdsp.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 - serious
html-has-langWCAG 3.1.1<html> element must have a lang attributehtml — The <html> element does not have a lang attribute - serious
listWCAG 1.3.1<ul> and <ol> must only directly contain <li>, <script> or <template> elementsul — List element has direct children that are not allowed: div - moderate
landmark-one-mainbest practiceDocument should have one main landmarkhtml — Document does not have a main landmark - moderate
page-has-heading-onebest practicePage should contain a level-one headinghtml — Page must have a level-one heading - moderate
regionbest practiceAll page content should be contained by landmarks.header, .title, input, p, h3, ul — Some page content is not contained by landmarks
- “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:
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:
- Tab from the address bar. Is there a skip link? Does focus go through the page in a sensible order?
- Can you always see focus? Watch for it disappearing behind sticky headers or on dark backgrounds.
- Can you operate everything? Buttons with Enter and Space, menus with arrows, dialogs with Escape.
- Do you ever get stuck, or lose focus to the top of the page after an action?
- After a dialog closes or an item is deleted, is focus somewhere sensible?
- 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 reader | Start / stop | Read next item | Useful lists |
|---|---|---|---|
| VoiceOver (macOS, built in) | ⌘+F5 | VO+→ (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 shortcut | swipe right | rotor: twist two fingers |
| TalkBack (Android) | Settings → Accessibility, or hold both volume keys if set as the shortcut | swipe right | reading 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: WCAG, the law, component libraries, React
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.
24 criteria shown (out of 86 in WCAG 2.2).
- 1.1.1Non-text ContentAImages and icons have a text alternative (alt, aria-label).
- 1.3.1Info and RelationshipsAStructure you can see (headings, lists, labels, tables) is in the markup too.
- 1.4.1Use of ColorAColour is never the only way information is shown.
- 1.4.3Contrast (Minimum)AAText contrast at least 4.5:1 (3:1 for large text).
- 1.4.4Resize TextAAText can be zoomed to 200% without losing content.
- 1.4.10ReflowAAsince 2.1Works at 320 CSS px wide without sideways scrolling.
- 1.4.11Non-text ContrastAAsince 2.1Input borders, icons and focus rings reach 3:1.
- 2.1.1KeyboardAEverything works with a keyboard alone.
- 2.1.2No Keyboard TrapAFocus can always move away again (a dialog's focus loop is fine because Escape/Close exits it).
- 2.4.1Bypass BlocksAA way to skip repeated blocks — a skip link or landmarks.
- 2.4.3Focus OrderATab order makes sense.
- 2.4.7Focus VisibleAAYou can see where keyboard focus is.
- 2.4.11Focus Not Obscured (Minimum)AAsince 2.2The focused element isn't completely hidden by sticky headers or banners.
- 2.5.3Label in NameAsince 2.1The accessible name contains the visible label text.
- 2.5.7Dragging MovementsAAsince 2.2Anything done by dragging can also be done with simple clicks.
- 2.5.8Target Size (Minimum)AAsince 2.2Click/tap targets are at least 24×24 CSS px, or spaced out enough.
- 3.1.1Language of PageA<html lang="…"> is set, so screen readers use the right voice.
- 3.2.6Consistent HelpAsince 2.2Help links/contact details appear in the same place on every page.
- 3.3.1Error IdentificationAErrors are described in text, not just a red border.
- 3.3.2Labels or InstructionsAInputs have labels or instructions.
- 3.3.7Redundant EntryAsince 2.2Don't make people type the same information twice in one process.
- 3.3.8Accessible Authentication (Minimum)AAsince 2.2Logging in doesn't require a memory or puzzle test; allow paste and password managers.
- 4.1.2Name, Role, ValueAEvery control exposes a name, a role and its current state.
- 4.1.3Status MessagesAAsince 2.1Status 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:
- 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 needdiv onClick. - 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) withoutlabel. Have<TextField label="Email" error={msg} />wire upid,htmlFor,aria-describedbyandaria-invalidinternally, so nobody can forget them. - Document the keyboard contract for each component: “Tabs: ←/→ move, Home/End jump, Tab leaves”. Test it with Playwright, like §10.
- Contrast in the tokens. If every text and background token pair is checked once, screens can't drift (§08).
- 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:
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'slistrule 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 withid="email"give both labels the same target, and one field ends up without a name.
Pitfalls gallery
Nine mistakes that show up in almost every audit, each with the fix.
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).
<div className="btn" onClick={save}>Save</div><button type="button" className="btn" onClick={save}>
Save
</button>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).
<button onClick={remove}>
<TrashIcon />
</button><button onClick={remove} aria-label="Delete invoice">
<TrashIcon aria-hidden="true" />
</button>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).
*:focus { outline: none; }:focus-visible {
outline: 3px solid #22d3ee;
outline-offset: 2px;
}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.
<aside class="drawer closed" aria-hidden="true">
<a href="/settings">Settings</a>
</aside><aside class="drawer closed" inert>
<a href="/settings">Settings</a>
</aside>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.
<input type="email" placeholder="Email"><label for="email">Email</label>
<input id="email" type="email" placeholder="name@example.com">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.
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.
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”.
<img src="q3.png">
<img src="divider.png" alt="divider image"><img src="q3.png" alt="Revenue up 40% in Q3">
<img src="divider.png" alt="">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).
Check yourself
Commit to an answer before you click. Each explanation points back to the section that covers it.
A keyboard-only user Tabs through the checkout page. What happens at this element?
<div className="btn" onClick={pay}>Pay $42</div><button aria-label="Close dialog">X</button>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>Body text is #767676 on a white background, 16px, normal weight. The contrast ratio is about 4.54:1. Under WCAG 2.2 AA…
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>}<div aria-hidden="true">
<button>Buy now</button>
</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>Cheat sheet
button for actions, a href for navigation, label for inputs, h1–h6 in order, header/nav/main/footer for landmarks.aria-expanded, aria-invalid, live regions.:focus-visible with at least 3:1 contrast. Never a bare outline: none.<dialog> + showModal() does all four. Move focus yourself after route changes and deletions.getByRole in tests, a 10-minute keyboard walkthrough, and a screen reader on key flows.| You need… | Use | Not |
|---|---|---|
| an action | <button type="button"> | <div onClick>, <a href="#"> |
| navigation | <a href="/x"> | <button onClick={navigate}> |
| a name for an icon button | aria-label="Delete invoice" + aria-hidden on the icon | nothing, or title alone |
| a field label | <label for> / htmlFor | placeholder alone |
| help or error text on a field | aria-describedby="hint err" + aria-invalid | red border only |
| show / hide toggle | <button aria-expanded> or <details> | a clickable div |
| an announcement | an 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 group | roving tabindex (0 on one item, -1 on the rest) | every item tabindex="0" |
| to hide from everyone | hidden / inert | aria-hidden on focusable content |
| motion | @media (prefers-reduced-motion: reduce) | autoplay with no pause |
| a quick audit | Chrome DevTools → Accessibility pane, Lighthouse, axe | an overlay script |