Skip link
The problem: A keyboard-only visitor lands on every page at the top and has to Tab through the whole masthead and navigation, often thirty or more stops, before reaching the content. On a ten-page journey that is three hundred keystrokes of pure overhead.
WCAG success criteria
- 2.4.1 Bypass Blocks (Level A, WCAG 2.0): A skip link is the canonical mechanism for bypassing blocks of content repeated on multiple pages. Correctly marked-up landmarks and headings are the other two accepted mechanisms.
- 2.4.3 Focus Order (Level A, WCAG 2.0): Activating the link must actually move focus to the target. If only the scroll position moves, the next Tab press continues from the link and dumps the user back in the navigation.
- 2.4.7 Focus Visible (Level AA, WCAG 2.0): The link is invisible until focused and fully visible once focused, so the keyboard user can see where they are. A skip link that stays hidden when focused fails this.
Section 508
Revised Section 508 (2017, compliance date 18 January 2018) does not define its own "skip navigation" provision. E205.4 requires electronic content to conform to WCAG 2.0 Level A and AA, which pulls in SC 2.4.1 Bypass Blocks directly. It also supports the Chapter 3 Functional Performance Criteria 302.1 (Without Vision) and 302.7 (With Limited Manipulation), since both groups pay the highest price for a long repeated tab sequence.
How to test with a keyboard
- Click once on the "Overview" link above, then press Shift+Tab until you are before it, or just reload and press Tab once.
- The first Tab press should reveal a "Skip to main content" link that was not visible a moment ago.
- Press Enter. Focus should jump to the "Main content" box.
- Press Tab once more. You should land on "First link inside main", not back in the navigation.
What a screen reader should announce
- "Skip to main content, link" as the very first announcement on the page.
- After activating it, the main region and its heading, not the navigation list again.
- In the broken version you will hear nothing at all: the link is not in the accessibility tree.
What the broken version does
The skip link is still in the HTML but hidden with display:none, so it is neither focusable nor exposed to assistive technology. Tab from the top and you go straight into the six navigation links.
Source of the working demo
// The link is the FIRST focusable thing in the document.
<a class="skip-link sr-only-focusable" href="#main">Skip to main content</a>
<nav aria-label="Primary">…30 links…</nav>
// The target is programmatically focusable but not a tab stop.
<main id="main" tabindex="-1">…</main>
/* Clipped, not display:none; display:none is unfocusable and
invisible to assistive technology. It becomes visible the
instant it receives focus. */
.sr-only-focusable:not(:focus):not(:focus-within) {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute; top: .5rem; left: .5rem; z-index: 100;
background: var(--accent); color: var(--accent-contrast);
padding: .6rem 1rem; border-radius: .5rem;
}
// Some browsers historically moved the scroll position but not
// focus. Belt and braces:
document.querySelector('.skip-link')
.addEventListener('click', (e) => {
e.preventDefault();
document.getElementById('main').focus();
});
Visible focus indicator
The problem: Someone navigating with a keyboard has exactly one piece of information about where they are on the page: the focus ring. Deleting it without a replacement does not make the site cleaner; it makes it unusable, in the same way that hiding the mouse cursor would.
WCAG success criteria
- 2.4.7 Focus Visible (Level AA, WCAG 2.0): Any keyboard-operable interface must have a mode of operation where the keyboard focus indicator is visible. A blanket outline:none with no replacement is a direct failure.
- 1.4.11 Non-text Contrast (Level AA, WCAG 2.1): When you do replace the default ring, the replacement must reach 3:1 contrast against its adjacent colours. A pale grey custom ring on a white card technically exists and still fails.
- 1.4.1 Use of Color (Level A, WCAG 2.0): A focus indicator that only shifts the hue of a button, with no change in border, weight, or shape, is not perceivable to a visitor with a colour vision deficiency.
Section 508
E205.4 incorporates WCAG 2.0 Level A and AA, which includes SC 2.4.7 Focus Visible. Note the version boundary honestly: SC 1.4.11 Non-text Contrast is a WCAG 2.1 addition and is therefore NOT incorporated by the 2017 Revised 508 Standards, though it is required by WCAG 2.1 AA and by the U.S. Department of Justice ADA Title II rule (2024), which adopts WCAG 2.1 AA for state and local government web content. WCAG 2.2 adds 2.4.11 Focus Not Obscured (Minimum) at AA and 2.4.13 Focus Appearance at AAA; neither is a 508 requirement.
How to test with a keyboard
- Click just above the demo, then press Tab five times to walk through the controls.
- In the accessible version every stop shows a thick ring offset from the control.
- Now click a button with the mouse: no ring appears, because :focus-visible knows the difference.
- In the broken version, press Tab five times and then press Enter. Something happened; you had no way to predict what.
What a screen reader should announce
- Nothing changes for a screen reader; this criterion is about visible focus, and screen-reader users get focus information spoken instead.
- The people this pattern protects are sighted keyboard users: RSI, tremor, temporary injury, power users, and anyone using switch or voice control.
What the broken version does
A scoped stylesheet sets outline: none on :focus and :focus-visible for everything in the demo. Focus still moves; you simply cannot see where it is.
Source of the working demo
/* The good version: one global rule, not per-component. */
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px; /* keeps the ring off the control's own border */
border-radius: 2px;
}
/* The ONLY safe way to write "remove the outline": remove it for
pointer focus, where :focus-visible has already decided the user
does not need it. Never a blanket `outline: none`. */
:focus:not(:focus-visible) {
outline: none;
}
/* If the design truly cannot accept an outline, replace it with an
indicator of equivalent visibility: a box-shadow ring, a border
swap, an inverted background. What is NOT acceptable is nothing.
The replacement needs 3:1 contrast against the adjacent colour
(SC 1.4.11 Non-text Contrast, AA). */
.card:focus-visible {
outline: none; /* replaced, not removed */
box-shadow: 0 0 0 3px var(--focus);
}
/* Windows High Contrast Mode strips box-shadow. Keep a transparent
outline so forced-colors mode paints a real one. */
@media (forced-colors: active) {
:focus-visible { outline: 3px solid CanvasText; }
}
Focus trap in a modal dialog
The problem: A modal that does not manage focus is a lie: it looks blocking but is not. Keyboard and screen-reader users Tab straight past it into the page underneath, filling in a form they cannot see, and when the dialog closes their focus is dumped at the top of the document with no memory of where they were.
WCAG success criteria
- 2.1.2 No Keyboard Trap (Level A, WCAG 2.0): Often quoted as a reason NOT to trap focus, which misreads it. 2.1.2 forbids a component focus cannot leave using standard keys. A dialog that closes on Escape provides that exit and conforms; one without an exit is the violation.
- 2.4.3 Focus Order (Level A, WCAG 2.0): Focus must move into the dialog when it opens and back to the trigger when it closes, so the sequence preserves meaning and operability.
- 4.1.2 Name, Role, Value (Level A, WCAG 2.0): role="dialog" plus aria-modal="true" plus aria-labelledby gives the layer a role and a name. A bare div has neither, so nothing announces that a dialog opened.
- 2.1.1 Keyboard (Level A, WCAG 2.0): Every dialog control, including the close affordance, must be reachable and operable from the keyboard. A close "X" that is a div with an onClick is not.
Section 508
E205.4 incorporates WCAG 2.0 A and AA, covering all four criteria above. Chapter 3 Functional Performance Criteria 302.1 (Without Vision) is the one this pattern speaks to most directly: without the role, the name, and the focus move, a blind user has no way to know a modal opened at all. Note that the Revised Standards also apply to software user interfaces via 502 and 503; the same dialog rules apply in a desktop or mobile app, not just on the web.
How to test with a keyboard
- Tab to "Delete this project…" and press Enter.
- Focus should already be inside the dialog, on the Delete button.
- Press Tab three or four times. Focus must cycle Delete → Cancel → Delete and never reach "Background button".
- Press Shift+Tab from Delete. It should wrap backwards to Cancel.
- Press Escape. The dialog closes and focus returns to "Delete this project…", not to the top of the page.
- Now switch to Broken and repeat: after two Tab presses you are in the background form, invisibly.
What a screen reader should announce
- "Delete project?, dialog", the name and the role, together, on open.
- Then the description, then "Delete, button".
- Browsing with the virtual cursor should stay inside the dialog; aria-modal="true" is what confines it.
- In the broken version you hear the previous button still focused, and nothing announces that anything opened.
What the broken version does
The dialog is a plain div: no role, no aria-modal, no label, focus never moves into it, Escape does nothing, Tab walks into the background form, and closing drops focus onto the body.
Source of the working demo
function useFocusTrap({ active, onEscape, returnFocusTo }) {
const containerRef = useRef(null);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
// 1. Remember where focus came from, so it can go back.
const previouslyFocused =
returnFocusTo?.current ?? document.activeElement;
// 2. Move focus in.
(getFocusable(container)[0] ?? container).focus();
const onKeyDown = (e) => {
// 3. Escape is the required keyboard exit. Without it this
// WOULD violate SC 2.1.2 No Keyboard Trap.
if (e.key === 'Escape') { e.stopPropagation(); onEscape(); return; }
if (e.key !== 'Tab') return;
// 4. Cycle. Recompute each time: dialog contents change.
const items = getFocusable(container);
if (items.length === 0) { e.preventDefault(); container.focus(); return; }
const first = items[0];
const last = items[items.length - 1];
const inside = container.contains(document.activeElement);
if (e.shiftKey ? (!inside || document.activeElement === first)
: (!inside || document.activeElement === last)) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
}
};
container.addEventListener('keydown', onKeyDown);
return () => {
container.removeEventListener('keydown', onKeyDown);
// 5. Restore. isConnected guards against the trigger having
// been removed by the action the dialog performed.
if (previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, [active, returnFocusTo]);
return containerRef;
}
// Markup
<div ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dlg-title"
aria-describedby="dlg-desc"
tabindex="-1">
<h2 id="dlg-title">Delete project?</h2>
<p id="dlg-desc">This cannot be undone.</p>
<button>Delete</button>
<button onClick={close}>Cancel</button>
</div>
// In production also mark the rest of the page inert, which removes
// it from the tab order AND the accessibility tree:
// <div id="app-root" inert={isModalOpen}>
Keyboard-operable custom controls
The problem: A div with an onClick handler looks and behaves like a button for exactly one kind of user: someone with a working mouse and working eyes. It is not focusable, has no role, exposes no state, and does not respond to Space or Enter. This is the single most common way an otherwise well-built interface becomes unusable.
WCAG success criteria
- 2.1.1 Keyboard (Level A, WCAG 2.0): All functionality must be operable through a keyboard interface. A div with no tabindex cannot receive focus, so its functionality is unreachable.
- 4.1.2 Name, Role, Value (Level A, WCAG 2.0): role="switch" supplies the role, aria-labelledby the name, aria-checked the value, and the value must update as the state changes. A styled div supplies none of the three.
- 2.4.3 Focus Order (Level A, WCAG 2.0): Opening a menu should move focus into it, and Escape should return focus to the trigger, so the sequence stays meaningful.
- 2.4.7 Focus Visible (Level AA, WCAG 2.0): A custom control that takes focus must show it. Custom widgets are where designers most often forget, because the default ring rarely suits a bespoke shape.
Section 508
All four are WCAG 2.0 criteria and are incorporated by E205.4 for web content and by 502.3 (Accessibility Services) and 503 for software user interfaces. 502.3.1 through 502.3.14 spell out, in software terms, essentially what 4.1.2 requires: object role, state, name, and value must be programmatically determinable and, where the user can set them, settable. Functional Performance Criteria 302.7 (With Limited Manipulation) and 302.8 (With Limited Reach and Strength) apply directly; these are the users for whom the keyboard, a switch device, or voice is the only input method.
How to test with a keyboard
- Tab to the switch. In the accessible version it takes focus and shows a ring; in the broken version Tab skips it entirely.
- Press Space, then Enter. Both should toggle it, and the visible "On/Off" text should change.
- Tab to the "Actions" button and press Enter or Down Arrow to open the menu.
- Press Down and Up to move between items, Home and End to jump to the ends.
- Press Escape: the menu closes and focus returns to the Actions button.
- In the broken version, open the menu with the mouse and then press Tab: focus skips straight past all four items.
What a screen reader should announce
- Accessible switch: "Email notifications, switch, off", then "on" when toggled.
- Accessible menu trigger: "Actions, menu pop-up button, collapsed", becoming "expanded".
- Menu items: "Duplicate, menu item, 1 of 4".
- Broken switch: read as a stray piece of text with no role and no state, if it is announced at all.
- Broken menu: four unrelated lines of text with no indication they are options.
What the broken version does
The switch loses role, tabindex, and aria-checked; it becomes a decorated div with a click handler. The menu items lose role and tabindex, and the trigger loses aria-haspopup and aria-expanded. Everything still works perfectly with a mouse.
Source of the working demo
{/* ── FIRST, THE HONEST ANSWER ───────────────────────────────
Do not build these from divs. A native control already has the
role, the focusability, the keyboard model, and correct
behaviour in Windows High Contrast Mode:
<button type="button" aria-pressed={on}>Notifications</button>
<input type="checkbox" role="switch" checked={on} … />
Everything below is what you must re-implement by hand if you
ignore that advice. ──────────────────────────────────────── */}
{/* Switch from a div */}
<div role="switch"
tabindex="0"
aria-checked={checked}
aria-labelledby="notif-label"
onClick={toggle}
onKeyDown={(e) => {
// Space AND Enter. Space must preventDefault or the page
// scrolls under the user.
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggle(); }
}} />
{/* Menu button + menu (WAI-ARIA Authoring Practices) */}
<button aria-haspopup="menu" aria-expanded={open} onClick={toggleMenu}>
Actions
</button>
<div role="menu" aria-label="Project actions" onKeyDown={onMenuKeyDown}>
{items.map((item, i) => (
<div role="menuitem"
tabindex={i === activeIndex ? 0 : -1}
ref={el => itemRefs.current[i] = el}>
{item}
</div>
))}
</div>
function onMenuKeyDown(e) {
switch (e.key) {
case 'ArrowDown': e.preventDefault(); next(); break;
case 'ArrowUp': e.preventDefault(); prev(); break;
case 'Home': e.preventDefault(); first(); break;
case 'End': e.preventDefault(); last(); break;
case 'Escape': e.preventDefault(); close(); trigger.focus(); break;
case 'Tab': close(); break; // no preventDefault: let them leave
case 'Enter':
case ' ': e.preventDefault(); activate(); close(); trigger.focus(); break;
}
}
Roving tabindex in a toolbar
The problem: Every button in a composite widget being its own tab stop turns a thirty-button editor toolbar into thirty keystrokes of obstacle between the user and the next thing on the page. Users of switch devices, who may press once every few seconds, feel this most acutely.
WCAG success criteria
- 2.1.1 Keyboard (Level A, WCAG 2.0): The widget and every item in it must be operable from the keyboard. The broken version claims the toolbar role but leaves the items with no tabindex, so none of them can be reached at all.
- 2.4.3 Focus Order (Level A, WCAG 2.0): Roving tabindex produces a focus sequence that matches the visual order and keeps the widget a single coherent stop rather than an obstacle course.
- 4.1.2 Name, Role, Value (Level A, WCAG 2.0): role="toolbar" is a promise about behaviour. Applying it to something that does not implement the arrow-key model is a role/behaviour mismatch, which is a 4.1.2 problem as much as a usability one.
Section 508
Incorporated through E205.4 (content) and 502/503 (software), which reference WCAG 2.0 Level A and AA. Roving tabindex is not named anywhere in the 508 text; it is a technique from the W3C WAI-ARIA Authoring Practices Guide, which is the reference implementation people mean when they say "the ARIA pattern". Functional Performance Criteria 302.7 With Limited Manipulation and 302.8 With Limited Reach and Strength are the ones that make the keystroke count a real accessibility issue and not just an ergonomics nicety.
How to test with a keyboard
- Tab from "Link before the toolbar". In the accessible version you land on exactly one toolbar button.
- Press Right Arrow several times. Focus moves along the toolbar and wraps around at the end.
- Press Home, then End, to jump to the first and last buttons.
- Press Tab. You leave the whole toolbar in one press and land on "Link after the toolbar".
- Press Shift+Tab back into the toolbar: focus returns to the button you last used, not to the first.
- In the broken version, Tab goes straight from the link before to the link after; the six controls do not exist for you.
What a screen reader should announce
- "Text formatting, toolbar" on entry, then "Align left, button, not pressed".
- Arrowing along should announce each button and its pressed state.
- In the broken version you hear the toolbar role announced but arrow keys do nothing; the widget promises a behaviour it does not have.
What the broken version does
The container keeps role="toolbar" while the items become divs with role="button" and no tabindex. Nothing inside is focusable, and arrow keys are not handled; the announced role and the actual behaviour contradict each other.
Source of the working demo
const [activeIndex, setActiveIndex] = useState(0);
const refs = useRef([]);
// Move focus only in response to a key press, never on mount.
const shouldFocus = useRef(false);
useEffect(() => {
if (shouldFocus.current) refs.current[activeIndex]?.focus();
}, [activeIndex]);
function onKeyDown(e) {
const last = items.length - 1;
const go = (i) => { shouldFocus.current = true; setActiveIndex(i); };
switch (e.key) {
// Wrapping is optional but conventional in a toolbar.
case 'ArrowRight': e.preventDefault(); go(activeIndex === last ? 0 : activeIndex + 1); break;
case 'ArrowLeft': e.preventDefault(); go(activeIndex === 0 ? last : activeIndex - 1); break;
case 'Home': e.preventDefault(); go(0); break;
case 'End': e.preventDefault(); go(last); break;
}
}
<div role="toolbar" aria-label="Text formatting"
aria-orientation="horizontal" onKeyDown={onKeyDown}>
{items.map((item, i) => (
<button ref={el => refs.current[i] = el}
tabIndex={i === activeIndex ? 0 : -1} // ← the whole trick
aria-label={item.label}
aria-pressed={isPressed(item)}
onFocus={() => setActiveIndex(i)}> // keep in sync on click
<span aria-hidden="true">{item.glyph}</span>
</button>
))}
</div>
/* Orientation and keys by role (WAI-ARIA Authoring Practices):
toolbar horizontal → ← → vertical → ↑ ↓ (set aria-orientation)
tablist horizontal → ← → + Home/End
listbox vertical → ↑ ↓ + Home/End, type-ahead
menu vertical → ↑ ↓ + Home/End, Esc, Tab closes
grid both → all four + Ctrl+Home/End
The alternative to real focus is aria-activedescendant: focus
stays on the container and one attribute points at the active
child. Fewer moving parts, but weaker AT support; prefer real
focus unless you have a reason. */
Accessible names for icon-only controls
The problem: A toolbar of pictograms is fast and compact for a sighted mouse user and completely opaque to everyone else. Without a name, a screen reader can only say "button", four times in a row, and a speech-input user has nothing to say out loud to activate it.
WCAG success criteria
- 4.1.2 Name, Role, Value (Level A, WCAG 2.0): Every user interface component must expose a name. An icon-only button with no label has a role (button) and a value but no name, which is a direct failure.
- 1.1.1 Non-text Content (Level A, WCAG 2.0): The icon is non-text content that conveys the control’s purpose, so it needs a text alternative serving the equivalent purpose.
- 2.5.3 Label in Name (Level A, WCAG 2.1): When a control does have visible text, the accessible name must contain that text. It does not apply to a purely graphical button, but it is the reason you must not "fix" a labelled button by giving it an unrelated aria-label.
Section 508
SC 4.1.2 and 1.1.1 are WCAG 2.0 criteria, incorporated by E205.4 for content and by 502/503 for software interfaces. Chapter 3 applies too: 302.1 Without Vision (nothing to announce) and 302.9 With Limited Language, Cognitive, and Learning Abilities (a picture with no word is ambiguous for far more people than screen-reader users). SC 2.5.3 Label in Name is a WCAG 2.1 addition and so is not part of the 2017 Revised 508 Standards, though it is required by WCAG 2.1 AA.
How to test with a keyboard
- Tab through the four toolbar buttons. They are reachable in both variants; this failure is invisible to a keyboard-only test.
- That is the lesson: keyboard operability and accessible naming are different problems, and passing one tells you nothing about the other.
- Compare the "Computed accessible names" list between the two variants.
What a screen reader should announce
- Accessible version: "Bold, button", "Italic, button", "Insert link, button", "Delete paragraph, button", "Undo, button".
- Broken version: "button", "button", "button", "button", four identical announcements, because the icon is aria-hidden and no label replaced it.
- The other common flavour, an exposed emoji with no label, announces the picture instead of the action: "wastebasket, button".
- In browser devtools, open the Accessibility pane and look at the Name field for each button; that is the authoritative answer, not the helper on this page.
What the broken version does
The first four buttons contain only an aria-hidden icon, with no aria-label and no visually hidden text, so they have no accessible name at all. The computed-names panel below shows exactly what is left for assistive technology to work with.
Source of the working demo
{/* Option 1: aria-label on the control, aria-hidden on the icon. */}
<button type="button" aria-label="Delete paragraph">
<span aria-hidden="true">🗑</span>
</button>
{/* Option 2: visually hidden real text. Slightly more robust:
it survives machine translation of the page, and it appears in
the DOM where a developer will actually notice it. */}
<button type="button">
<svg aria-hidden="true" focusable="false" width="16" height="16">…</svg>
<span class="sr-only">Delete paragraph</span>
</button>
{/* Option 3: an SVG that IS the content, named by <title>. */}
<svg role="img" aria-labelledby="trash-title" width="16" height="16">
<title id="trash-title">Delete paragraph</title>
<path d="…" />
</svg>
{/* WRONG: title attribute only. Not reliably announced, invisible to
touch users, and only appears after a hover delay. */}
<button title="Delete">🗑</button>
{/* WRONG: nothing at all. Announced as "button", or worse, as the
Unicode name of the emoji: "wastebasket, button". */}
<button>🗑</button>
/* The .sr-only class this depends on. Note it is CLIPPED, not
display:none; display:none removes it from the accessibility
tree, which defeats the entire purpose. */
.sr-only {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
Live regions for async status
The problem: Something finished, failed, or changed count, and the only evidence is a piece of text that appeared somewhere the user is not looking. Sighted users catch it peripherally. A screen-reader user, whose attention is wherever their cursor is, is told nothing at all.
WCAG success criteria
- 4.1.3 Status Messages (Level AA, WCAG 2.1): Status messages that do not receive focus must be programmatically determinable through role or properties, so assistive technology can present them. This is exactly what role="status" and role="alert" do.
- 3.3.1 Error Identification (Level A, WCAG 2.0): When the failure above is an input error, it must additionally be identified and described in text, which the assertive region does.
- 1.4.1 Use of Color (Level A, WCAG 2.0): The failure message says the word "failed". Turning the text red and stopping there conveys the state by colour alone.
Section 508
This is the clearest example on the site of a version boundary that matters. SC 4.1.3 Status Messages is NEW in WCAG 2.1, so it is not incorporated by the 2017 Revised Section 508 Standards, which reference WCAG 2.0 Level A and AA via E205.4. Claiming "508 requires aria-live" would be wrong. What is true: 4.1.3 is required by WCAG 2.1 AA, by EN 301 549, and by the U.S. DOJ ADA Title II rule (2024) for state and local government. Under 508 alone, the closest binding hooks are Chapter 3 Functional Performance Criteria 302.1 Without Vision and 302.2 With Limited Vision; an unannounced status message means the information simply is not available to those users.
How to test with a keyboard
- Activate "Save (polite status)" with Enter and leave focus on the button.
- The text below changes to "Saving…" and then "Saved. 3 records updated.", with no focus movement at all. That is the point: focus stays put.
- Activate the upload button and note the alert appears the same way.
What a screen reader should announce
- Accessible version: after pressing Save you hear "Saving…", then, about a second later, "Saved. 3 records updated." without touching anything.
- The assertive alert should cut in over whatever is being spoken.
- Broken version: total silence. The text is on the screen and is not in any live region, so nothing is ever announced.
- NVDA tip: use the speech viewer (NVDA menu → Tools → Speech Viewer) to see announcements as text, much faster than listening while you iterate.
What the broken version does
Both regions lose their role and aria-live attributes, and they are only mounted once there is a message, so even a screen reader that polls would have nothing to subscribe to. The text is visible and completely silent.
Source of the working demo
{/* Render the region on FIRST paint and keep it mounted.
Assistive tech watches existing live regions for mutations; a
region that appears at the same instant as its text is routinely
missed. This is the number-one live-region bug. */}
<div role="status" aria-live="polite" aria-atomic="true">
{statusText}
</div>
<div role="alert" aria-live="assertive">
{errorText}
</div>
/* Which to use
─────────────────────────────────────────────────────────────
role="status" ≡ aria-live="polite" queued, non-interrupting
role="alert" ≡ aria-live="assertive" interrupts immediately
role="log" ≡ polite + relevant="additions" chat, console
role="timer" ≡ off by default; announce explicitly instead
aria-atomic="true" read the WHOLE region on any change
aria-atomic="false" read only what changed (default)
aria-relevant which mutations count (additions text, default)
aria-busy="true" suppress announcements while a batch renders
*/
// If the update is a direct response to the user's own action on
// the control they are focused on, prefer MOVING FOCUS to the new
// content over announcing it. Live regions are for things that
// happen without the user asking right now.
// React StrictMode / fast re-render caveat: setting the same string
// twice does not re-announce. If a repeat announcement matters
// (e.g. "still saving…"), vary the text or clear then set on a tick.
Form labels, instructions, and errors
The problem: Forms are where accessibility failures become expensive, because a form is usually the point of the whole page. An unlabelled field is announced as "edit, blank"; a validation error shown only as a red border is invisible; and a failed submit that leaves focus on the button gives a screen-reader user no idea anything went wrong.
WCAG success criteria
- 1.3.1 Info and Relationships (Level A, WCAG 2.0): The relationship between a label, its hint, its error message, and the input must be programmatic, not just visual proximity. That is what for/id and aria-describedby encode.
- 3.3.2 Labels or Instructions (Level A, WCAG 2.0): Labels or instructions are provided when content requires user input. The format hint for the case reference is the instruction half of this criterion.
- 3.3.1 Error Identification (Level A, WCAG 2.0): If an input error is detected, the item in error is identified and described to the user in text. A red border alone identifies nothing in text.
- 3.3.3 Error Suggestion (Level AA, WCAG 2.0): When you know how to fix it, say so. "Enter an email address in the form name@example.com" passes; "Invalid email" does not.
- 1.4.1 Use of Color (Level A, WCAG 2.0): Required fields and errored fields must be identifiable without perceiving colour. Hence the word "(required)" and the ✕ glyph beside the message.
- 4.1.2 Name, Role, Value (Level A, WCAG 2.0): aria-invalid is the programmatic "value" of the field’s validity state, so assistive technology can announce "invalid entry" when the user returns to the field.
- 1.3.5 Identify Input Purpose (Level AA, WCAG 2.1): autocomplete="email" identifies the purpose of the field from the standard token list, which enables autofill and personalisation tools.
- 2.4.6 Headings and Labels (Level AA, WCAG 2.0): Labels must describe the purpose. "Reference" is worse than "Case reference"; "Enter text here" is worthless.
Section 508
The 2.0-era criteria here (1.3.1, 3.3.1, 3.3.2, 3.3.3, 1.4.1, 4.1.2, 2.4.6) are all incorporated by E205.4 for content and 502/503 for software. SC 1.3.5 Identify Input Purpose is a WCAG 2.1 addition and is therefore NOT part of the 2017 Revised 508 Standards; it is included here because it is required by WCAG 2.1 AA and is genuinely useful. Functional Performance Criteria that bear on forms: 302.1 Without Vision, 302.3 Without Perception of Color (the required-field indicator), and 302.9 With Limited Language, Cognitive, and Learning Abilities (error messages that explain the fix rather than restating that something is wrong).
How to test with a keyboard
- Tab through the form. Each field should announce its label; in the broken version they announce nothing useful.
- Click the visible "Email address" text. In the accessible version this focuses the input (that is what a real <label> does); in the broken version nothing happens.
- Leave everything empty and press Enter on Submit.
- Focus should jump to the error summary at the top of the form.
- Tab to the first error link and press Enter: focus moves to the field that needs fixing.
- Type an email without an @ and submit again. The message should tell you what would be valid.
What a screen reader should announce
- Accessible: "Email address (required), edit, We will only use this to reply. Format: name at example dot com".
- After a failed submit: "There are 2 problems with this form" read immediately, because focus moved there.
- Returning to the errored field: "Email address, invalid entry, edit, … Enter an email address in the form name@example.com".
- Broken: "edit, blank" for every field, and after submitting, silence.
What the broken version does
Labels become placeholders or unassociated spans, error messages are visually adjacent but not linked by aria-describedby, aria-invalid is never set, required is shown by a red asterisk only, and the error summary neither announces nor takes focus.
Source of the working demo
{/* 1. Label association: <label for> ↔ id. Nothing else. */}
<label for="email">
Email address <span class="hint">(required)</span>
</label>
{/* 2. Instructions BEFORE the input, wired with aria-describedby
so they are announced as part of the field. */}
<span class="hint" id="email-hint">
We will only use this to reply. Format: name@example.com
</span>
<input id="email"
type="email"
required
autocomplete="email" {/* SC 1.3.5 (AA, WCAG 2.1) */}
aria-invalid={hasError || undefined}
{/* Keep the hint AND add the error. Space-separated id list,
in the order you want them read. */}
aria-describedby={hasError ? "email-hint email-error" : "email-hint"} />
{hasError && (
<span class="field-error" id="email-error">
<span aria-hidden="true">✕ </span>
Enter an email address in the form name@example.com; it must include an @.
</span>
)}
{/* 3. Error summary that TAKES FOCUS on submit failure. */}
<div ref={summaryRef} tabindex="-1" role="alert"
aria-labelledby="summary-heading">
<h2 id="summary-heading">There are 2 problems with this form</h2>
<ul>
<li><a href="#email" onClick={focusField}>Enter an email address.</a></li>
</ul>
</div>
function onSubmit(e) {
e.preventDefault();
const errs = validate(values);
setErrors(errs);
if (Object.keys(errs).length) {
// After paint, so the summary exists to receive focus.
setTimeout(() => summaryRef.current.focus(), 0);
}
}
/* Required indication that is not colour-only:
✅ the word "(required)" in the label, plus the required attribute
✅ "All fields are required unless marked optional"
❌ a red asterisk and nothing else
If you must use an asterisk, keep the required attribute AND
explain the convention in text before the first field. */
/* Anti-patterns, all of which look fine to a sighted mouse user:
❌ placeholder as the only label
❌ a <span> styled to look like a label, with no for=
❌ aria-label that differs from the visible label (breaks 2.5.3)
❌ "Invalid input" with no explanation of what would be valid
❌ errors announced only by turning the border red
❌ title="…" as the label, hover-only, and unreliable */
Headings and landmarks
The problem: Screen-reader users do not read pages top to bottom; they skim by heading and by landmark, the same way a sighted reader skims by looking. A page of unlabelled divs with font-size headings removes both. Surveys of screen-reader users have consistently put headings at the top of the list of how they find things on a page.
WCAG success criteria
- 1.3.1 Info and Relationships (Level A, WCAG 2.0): A visual heading must also be a programmatic heading. Bold 1.4rem text conveys structure visually and conveys nothing to assistive technology.
- 2.4.1 Bypass Blocks (Level A, WCAG 2.0): Correctly marked-up landmarks are one of the three accepted ways to satisfy this criterion, alongside a skip link and a proper heading structure.
- 2.4.6 Headings and Labels (Level AA, WCAG 2.0): Headings must describe the topic or purpose. "Overview" on six different sections describes nothing.
- 2.4.10 Section Headings (Level AAA, WCAG 2.0): Stated honestly: the requirement to use headings to organise content is Level AAA, not AA. What is required at A is that any heading you DO use is marked up as one.
Section 508
E205.4 incorporates WCAG 2.0 A and AA, which covers 1.3.1, 2.4.1 and 2.4.6. Note the honest boundary: SC 2.4.10 Section Headings is Level AAA, so neither WCAG AA nor Section 508 requires you to add headings that are not already there; they require that structure you do present visually is also present programmatically. Chapter 3 Functional Performance Criterion 302.1 Without Vision is the practical driver, and 302.9 (Limited Language, Cognitive, and Learning Abilities) benefits too, since a clear outline helps anyone who finds long prose hard going.
How to test with a keyboard
- Headings and landmarks have no keyboard behaviour of their own; this is a structure problem a keyboard test cannot find.
- The nearest keyboard proxy: press Tab from the top of the page and see whether a skip link offers to bypass the navigation.
- In Firefox or Chrome devtools, open the Accessibility tree and look at the top-level structure.
What a screen reader should announce
- NVDA: press H to move to the next heading, 1–6 for a specific level, D for the next landmark, and Insert+F7 for the elements list.
- JAWS: H for headings, R for regions/landmarks, Insert+F6 for the headings list, Insert+Ctrl+R for the regions list.
- VoiceOver: Ctrl+Option+U opens the rotor; then arrow to the Headings or Landmarks list.
- On this page you should hear "banner", "navigation, Site and pattern index", "main", "contentinfo".
- On the broken demo, the landmark list is empty and the headings list contains one stray level-4 and one level-6.
What the broken version does
The mini-page is entirely divs with font-size standing in for heading levels, plus one real h4 followed by an h6 so the outline visibly skips a level. The scanner reports what is left: no landmarks, a broken outline.
Source of the working demo
<body>
<a class="skip-link" href="#main">Skip to main content</a>
{/* <header> at the top level → role="banner". Inside an <article>
it is NOT a landmark, which trips people up constantly. */}
<header>
<h1>Section 508 Patterns</h1> {/* exactly one h1 per page */}
</header>
{/* Multiple navigations MUST be distinguished by name, or the
landmarks list reads "navigation, navigation, navigation". */}
<nav aria-label="Site and pattern index"> … </nav>
<main id="main" tabindex="-1">
<h2>Patterns</h2> {/* h1 → h2, no skipping */}
<article aria-labelledby="skip-link-title">
<h3 id="skip-link-title">Skip link</h3>
<h4>Live demo</h4> {/* h3 → h4 */}
</article>
</main>
<aside aria-label="Related reading"> … </aside> {/* complementary */}
<footer> … </footer> {/* contentinfo */}
</body>
/* Implicit landmark roles: use the ELEMENT, not the role attribute,
because the element also carries the browser behaviour:
<header> banner (top level only)
<nav> navigation
<main> main (one per page)
<aside> complementary (top level only)
<footer> contentinfo (top level only)
<form> form ONLY when it has an accessible name
<section> region ONLY when it has an accessible name
<search> search (or role="search" for older browsers)
Rules that actually matter:
• One <main>, one <h1>, per page.
• Never skip a heading level going down (h2 → h4 is a failure of
the outline, though WCAG does not name the skip itself).
• Name every landmark you have more than one of.
• Do NOT put "Navigation" in aria-label on a <nav>; the role is
already announced. aria-label="Primary navigation" produces
"Primary navigation navigation". Just "Primary".
• Every visible piece of the page should be inside SOME landmark;
content outside them is unreachable by landmark navigation. */
Data tables with real headers
The problem: A table without header semantics is a grid of numbers with the meaning stripped out. A sighted user reconstructs "which row, which column" from position in a fraction of a second; a screen-reader user, hearing one cell at a time, gets "1,022" and nothing else.
WCAG success criteria
- 1.3.1 Info and Relationships (Level A, WCAG 2.0): The row/column relationship is information conveyed through presentation. It must also be available programmatically, which is exactly what th and scope do.
- 1.3.2 Meaningful Sequence (Level A, WCAG 2.0): A layout table forces a reading sequence that may not match the visual one. Once the visual arrangement carries meaning, the underlying order has to carry it too.
- 2.4.6 Headings and Labels (Level AA, WCAG 2.0): The caption is the table’s label and must describe its topic or purpose. "Table 1" describes nothing.
Section 508
Incorporated by E205.4 via WCAG 2.0 A and AA. Worth knowing the history: the ORIGINAL 1998 Section 508 standards had explicit table provisions, §1194.22(g) "Row and column headers shall be identified for data tables" and (h) for multi-level headers. Those provisions were REPLACED by the 2017 refresh, which references WCAG instead. If you see a requirements document citing 1194.22(g), it is quoting the superseded standard. Functional Performance Criterion 302.1 Without Vision is what makes this concrete.
How to test with a keyboard
- Tables are not interactive, so there is nothing to Tab to, which is precisely why a keyboard-only test finds nothing wrong here.
- If the table scrolls horizontally, its scroll container must be focusable so a keyboard user can reach the right-hand columns. Tab to the table region and press the arrow keys.
What a screen reader should announce
- Accessible: "Units shipped by region, first three quarters, table with 5 rows and 4 columns."
- Moving cell to cell: "South, Q2, 1,022", row header, column header, value.
- Broken: "1,022" alone. The number is there; the meaning is not.
- NVDA/JAWS table navigation: Ctrl+Alt+arrow keys. VoiceOver: Ctrl+Option+arrow keys inside the table.
- Both NVDA and JAWS can list all tables on a page (NVDA: press T; JAWS: press T). The caption is what appears in that list.
What the broken version does
The data table uses <td> for every cell with bold styling standing in for header semantics, and has no caption. A second example shows a layout table, which announces a table structure that means nothing.
Source of the working demo
<table>
{/* The caption IS the table's accessible name, and it appears in
the screen reader's list-of-tables dialog. A heading above the
table is not associated with it. */}
<caption>Units shipped by region, first three quarters</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Q3</th>
</tr>
</thead>
<tbody>
<tr>
{/* Row headers matter as much as column headers, and are the
ones people forget. */}
<th scope="row">North</th>
<td>1,204</td><td>1,338</td><td>1,411</td>
</tr>
</tbody>
</table>
{/* Irregular tables (merged cells, two header rows) need explicit
pairing. Prefer splitting into simple tables instead; this is
fragile and almost never maintained correctly. */}
<th id="h-q1-units">Units</th>
<td headers="h-region-north h-q1 h-q1-units">1,204</td>
{/* A table used for layout, if you cannot remove it, must have its
semantics suppressed so it is not announced as a data table: */}
<table role="presentation"> … </table>
{/* Responsive tables: do NOT set display:block / display:flex on
table elements to make them stack; that DESTROYS the table
semantics in the accessibility tree. Wrap it in a scroll
container instead, and make the container focusable so a
keyboard user can scroll it: */}
<div class="table-scroll" tabindex="0" role="region"
aria-label="Units shipped by region">
<table> … </table>
</div>
Colour contrast, and colour as the only signal
The problem: Low contrast is the most-reported accessibility failure on the web, year after year, and it is entirely mechanical to detect. Separately and just as importantly: roughly 1 in 12 men has some form of colour vision deficiency, so red-versus-green as the only difference between "passed" and "failed" is information that simply does not arrive.
WCAG success criteria
- 1.4.3 Contrast (Minimum) (Level AA, WCAG 2.0): Text and images of text need a contrast ratio of at least 4.5:1, or 3:1 for large text. This is the threshold Section 508 actually requires.
- 1.4.1 Use of Color (Level A, WCAG 2.0): Colour must not be the only visual means of conveying information, indicating an action, prompting a response, or distinguishing an element. The broken variant is this failure in its purest form.
- 1.4.11 Non-text Contrast (Level AA, WCAG 2.1): Interface component boundaries and meaningful graphics need 3:1. This is why a pale grey input border or a faint focus ring fails even when the text inside is fine.
- 1.4.6 Contrast (Enhanced) (Level AAA, WCAG 2.0): The 7:1 / 4.5:1 tier. Included for completeness and clearly marked AAA; it is not a Section 508 or WCAG AA requirement, and presenting it as one is a common way to lose an argument with a designer for no reason.
Section 508
SC 1.4.3 and 1.4.1 are WCAG 2.0 Level A/AA criteria and are incorporated by E205.4. Two things worth being precise about: SC 1.4.11 Non-text Contrast is a WCAG 2.1 addition and so is not a 2017 Revised 508 requirement, and SC 1.4.6 is Level AAA and is not required by either. Chapter 3 Functional Performance Criteria 302.2 (With Limited Vision) and 302.3 (Without Perception of Color) are the 508 provisions that speak to this most directly, and 302.3 is unusually explicit for a functional criterion; it requires a mode of operation that does not require user perception of colour.
How to test with a keyboard
- Tab into the colour fields and type a hex value. The ratio and every verdict update as you type.
- Tab to Swap and press Enter: the ratio is unchanged, because contrast is symmetric.
- Enter an unparseable value like "blueish": the field is marked aria-invalid and an error explains what is accepted.
What a screen reader should announce
- Typing a new colour should produce a polite announcement of the new ratio, because the results sit in a role="status" region.
- The results table announces "AA normal text, Needs 4.5 to 1, Result Pass": the verdict is a word, so it survives being spoken.
- In the broken variant the same region announces only "AA, normal text" with no verdict at all. The pass/fail lives entirely in a coloured square.
What the broken version does
The verdict table is replaced by a list of coloured squares. The information is present on screen and encoded purely in hue: nothing to read, nothing to announce, and indistinguishable to a visitor with red–green colour blindness.
Source of the working demo
/** Linearise one 8-bit sRGB channel.
* The 0.03928 branch is normative: this is NOT a plain gamma 2.2. */
function linearise(channel8Bit) {
const c = channel8Bit / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
/** WCAG relative luminance. Coefficients are Rec. 709 primaries. */
function relativeLuminance({ r, g, b }) {
return 0.2126 * linearise(r)
+ 0.7152 * linearise(g)
+ 0.0722 * linearise(b);
}
/** Contrast ratio. The +0.05 models ambient flare, which is why the
* maximum is 21:1 (black on white) and not infinity. */
function contrastRatio(fg, bg) {
const l1 = relativeLuminance(fg);
const l2 = relativeLuminance(bg);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
/* Thresholds
───────────────────────────────────────────────────────────────
SC 1.4.3 Contrast (Minimum) AA 4.5:1 normal, 3:1 large
SC 1.4.6 Contrast (Enhanced) AAA 7:1 normal, 4.5:1 large
SC 1.4.11 Non-text Contrast AA 3:1 UI components, graphics
"Large" = 18pt (24px), or 14pt bold (18.66px bold).
Exempt from 1.4.3: disabled controls, pure decoration, logotypes,
and text that is part of a picture of significant other content.
"Disabled" is an exemption people over-claim; a disabled control
nobody can read is still a usability failure.
Round DOWN when you display the ratio. 4.4996 shown as "4.50,
passes" is a quiet lie. */
/* Anti-pattern this criterion does NOT cover, but 1.4.1 does:
using colour as the ONLY way to convey something. Add a glyph,
a word, a shape, an underline, anything that survives being
printed in greyscale. */
Images, icons, and alternative text
The problem: Alternative text is the oldest accessibility requirement on the web and still the most consistently botched, in both directions: information-carrying images with no alt at all, and decorative flourishes described in loving detail on every page.
WCAG success criteria
- 1.1.1 Non-text Content (Level A, WCAG 2.0): All non-text content needs a text alternative serving an equivalent purpose, except when it is pure decoration, in which case it must be implemented so assistive technology ignores it. Both halves of that sentence are in the criterion.
- 1.4.5 Images of Text (Level AA, WCAG 2.0): Use real text rather than a picture of text, unless the exact presentation is essential. Real text zooms, reflows, recolours, and translates; a JPEG of a heading does none of that.
- 1.4.11 Non-text Contrast (Level AA, WCAG 2.1): An icon that conveys meaning needs 3:1 contrast against its background, the same as a control boundary. A pale grey status icon is unreadable regardless of its alt text.
Section 508
SC 1.1.1 and 1.4.5 are WCAG 2.0 A/AA criteria, incorporated by E205.4. As with tables, there is history worth knowing: the ORIGINAL 1998 standard stated this directly at §1194.22(a), "A text equivalent for every non-text element shall be provided", and that provision was superseded by the 2017 refresh. Chapter 3 Functional Performance Criteria 302.1 Without Vision and 302.2 With Limited Vision apply. SC 1.4.11 is WCAG 2.1 and therefore outside the 508 reference.
How to test with a keyboard
- Images are not focusable, so a keyboard test finds nothing here; as with tables, that is the point worth internalising.
- One keyboard-adjacent check: an inline SVG inside a link or button must not become its own tab stop. focusable="false" prevents that in legacy engines.
What a screen reader should announce
- Accessible chart: "Units shipped per quarter: Q1 400, Q2 580, Q3 760, Q4 900, a steady rise across the year, image."
- Accessible flourish: complete silence, which is correct.
- Broken chart: the data URI read aloud as a stream of characters, or simply "image".
- Broken flourish: "Blue decorative swoosh divider graphic, image", on every page, forever.
- Quick manual audit in any browser: disable images (Chrome: Settings → Site settings → Images) and read the page. What you can still understand is what a screen-reader user gets.
What the broken version does
The informative chart loses its alt attribute entirely, the decorative flourish gains a detailed description of itself, and the decorative inline SVG loses aria-hidden and focusable="false".
Source of the working demo
{/* INFORMATIVE: the alt carries the information, not a
description of the picture. */}
<img src="q4-shipments.svg"
alt="Units shipped per quarter: Q1 400, Q2 580, Q3 760, Q4 900,
a steady rise across the year." />
{/* COMPLEX: when one sentence is not enough, put the real data
next to it and point at it. */}
<img src="revenue.svg" alt="Revenue by region, 2024. Full data in
the table below." />
<table> … </table>
{/* DECORATIVE: alt is PRESENT and EMPTY. Not missing. */}
<img src="flourish.svg" alt="" />
{/* FUNCTIONAL: the image is inside a link or button, so the alt
describes the ACTION, not the picture. */}
<a href="/"><img src="logo.svg" alt="Home"></a>
{/* not alt="Company logo"; the user cannot "click a logo" */}
{/* INLINE SVG, decorative */}
<svg aria-hidden="true" focusable="false" viewBox="0 0 16 16">…</svg>
{/* INLINE SVG, meaningful */}
<svg role="img" aria-labelledby="warn-title" viewBox="0 0 16 16">
<title id="warn-title">Warning: 3 unresolved issues</title>
…
</svg>
{/* TEXT IN IMAGES: SC 1.4.5 Images of Text (AA) says use real
text unless the presentation is essential (logotypes are the
main exemption). Real text also zooms, reflows, translates,
and can be selected. */}
/* Decision tree
────────────────────────────────────────────────────────────
Does the image convey information the surrounding text does not?
no → alt="" (or a CSS background, which is better still)
yes → is it inside a link or button?
yes → alt describes the destination or action
no → alt conveys the information, as a sentence
Is the information too complex for a sentence?
yes → short alt + the full content in adjacent text/table */
Reflow at 320px and text resize to 200%
The problem: A low-vision user browsing a desktop site at 400% zoom is effectively on a 320-pixel-wide viewport. If the layout has any fixed pixel width in it, they get a page that scrolls both ways, meaning every single line of text requires a horizontal scroll to read, then a scroll back. It is the difference between slow and impossible.
WCAG success criteria
- 1.4.4 Resize Text (Level AA, WCAG 2.0): Text must scale to 200% without assistive technology and without loss of content or functionality. Fixed heights with hidden overflow are the classic failure, and this criterion IS incorporated by Section 508.
- 1.4.10 Reflow (Level AA, WCAG 2.1): Content must reflow to 320 CSS pixels wide without requiring scrolling in two dimensions. Data tables and complex diagrams are explicitly exempted from the no-horizontal-scroll rule.
- 1.4.12 Text Spacing (Level AA, WCAG 2.1): Nothing may be lost when the user forces line-height to 1.5, paragraph spacing to 2×, letter-spacing to 0.12em and word-spacing to 0.16em. A layout that already survives 200% text usually survives this unchanged.
Section 508
This pattern is a good example of why version matters. SC 1.4.4 Resize Text is WCAG 2.0 Level AA and IS incorporated by Section 508 via E205.4. SC 1.4.10 Reflow and SC 1.4.12 Text Spacing are both WCAG 2.1 additions and are therefore NOT part of the 2017 Revised 508 Standards; they are required by WCAG 2.1 AA, by EN 301 549, and by the DOJ ADA Title II rule (2024). If a procurement document asks for "508 compliance" and you build to WCAG 2.1 AA, you have exceeded the requirement, which is the correct direction to err. Functional Performance Criterion 302.2 With Limited Vision is the underlying 508 hook.
How to test with a keyboard
- Set the container width to 320px. In the accessible version everything stacks; in the broken version a horizontal scrollbar appears inside the frame.
- Set the text size to 200%. In the broken version the dashed box clips its own content and there is no way to recover it.
- Drag the bottom-right corner of the frame; it is a resizable element, so you can try intermediate widths.
- For the real test: press Ctrl and + (Cmd and + on macOS) five times to reach 400% browser zoom on a 1280px window, and check that no page-level horizontal scrollbar appears.
What a screen reader should announce
- Reflow is not a screen-reader concern; its users are people with low vision using magnification or zoom.
- One overlap worth knowing: if you fix reflow by hiding content at narrow widths, you may create a 1.4.13 or 2.4.3 problem instead. Reflow means rearranged, not removed.
What the broken version does
The inner layout is pinned to 900px, the heading and paragraph use white-space: nowrap, one box has a fixed height with overflow hidden, and two columns have flex-shrink: 0 so they can never stack.
Source of the working demo
/* ── REFLOW (SC 1.4.10, AA, WCAG 2.1) ──────────────────────────
Target: usable at 320 CSS px wide with no two-dimensional
scrolling. 320px = 1280px at 400% zoom, which is how a low-vision
desktop user actually gets there. */
/* 1. Never set a width in px on a layout container. */
.card { width: 100%; max-width: 60rem; } /* not width: 960px */
/* 2. Let flex children shrink. flex-basis + wrap, and minWidth: 0
because the default min-width:auto refuses to shrink below
content size: the number-one cause of mystery overflow. */
.row { display: flex; flex-wrap: wrap; gap: 1rem; }
.col { flex: 1 1 18rem; min-width: 0; }
/* 3. Same for grid: minmax(0, 1fr), not 1fr. */
.shell { display: grid; grid-template-columns: minmax(0,1fr); }
/* 4. Long unbreakable strings (URLs, hashes, code) overflow
everything. */
.prose { overflow-wrap: anywhere; }
/* 5. Wide things scroll INSIDE their own box, not the page. Make
the box focusable so a keyboard user can scroll it. */
.table-scroll { overflow-x: auto; }
<div class="table-scroll" tabindex="0" role="region" aria-label="…">
/* ── RESIZE TEXT (SC 1.4.4, AA, WCAG 2.0, a real 508 requirement) ──
Target: 200% text size with no loss of content or functionality. */
/* 6. min-height, never height, on anything containing text. */
.badge { min-height: 2rem; } /* not height: 2rem */
/* 7. Never overflow: hidden on a text container "to keep it tidy".
Tidy at 100% is deleted at 200%. */
/* 8. Do not pin the root font size; that overrides the visitor's
browser preference outright. */
html { font-size: 16px; } /* ❌ */
html { } /* ✅ inherit the browser default */
/* 9. Never disable pinch-zoom. This is a real, shipped, common
failure of 1.4.4 on mobile. */
<meta name="viewport" content="width=device-width, initial-scale=1">
{/* ❌ maximum-scale=1, ❌ user-scalable=no */}
/* ── TEXT SPACING (SC 1.4.12, AA, WCAG 2.1) ────────────────────
The user may override, and nothing may be lost:
line-height 1.5 × font size
paragraph gap 2 × font size
letter-spacing 0.12em
word-spacing 0.16em
Test it by pasting that as a user stylesheet. If your layout
already uses min-height and wrapping, it will pass unchanged. */
Respecting reduced motion
The problem: Large or repetitive movement can cause nausea, dizziness, and migraine in people with vestibular disorders, and it makes content hard to read for anyone with an attention or reading difficulty. The operating system already knows who those people are; the site just has to ask.
WCAG success criteria
- 2.2.2 Pause, Stop, Hide (Level A, WCAG 2.0): This is the criterion that actually binds at Level A: anything moving, blinking or scrolling that starts automatically, runs more than five seconds, and is presented with other content must have a mechanism to pause, stop, or hide it.
- 2.3.1 Three Flashes or Below Threshold (Level A, WCAG 2.0): Nothing may flash more than three times in any one second. Unlike the rest of this pattern, this is a seizure-safety rule, not a comfort preference.
- 2.3.3 Animation from Interactions (Level AAA, WCAG 2.1): Stated accurately: the criterion that specifically requires honouring reduced-motion for interaction animations is Level AAA and WCAG 2.1. Honouring the media query is still the right thing to do; it is simply not what makes you compliant at AA.
Section 508
Be precise here, because this is a pattern people over-claim. Section 508 incorporates WCAG 2.0 Level A and AA via E205.4, so SC 2.2.2 Pause, Stop, Hide and SC 2.3.1 Three Flashes are 508 requirements. SC 2.3.3 Animation from Interactions, the criterion actually about prefers-reduced-motion, is WCAG 2.1 AND Level AAA, so it is required by neither Section 508 nor WCAG AA. Separately, the Revised 508 Standards do have a directly relevant hardware/software provision: 503.4 requires user controls for captions and audio description, and Chapter 3 Functional Performance Criterion 302.9 covers limited cognitive and learning abilities. Honour the media query because it helps real people, and cite 2.2.2 when you need a requirement to point at.
How to test with a keyboard
- Tab to "Pause animation" and press Enter. The motion should stop and the button label should change.
- Tab to "Simulate reduce motion" and press Space. The square should snap to its resting position with no movement.
- Turn the setting on for real in your OS and reload: the animation should never start.
- In the broken version, look for the pause control. There is not one.
What a screen reader should announce
- The animated square is aria-hidden, so it is announced not at all; correct, because it carries no information.
- The pause button announces its current action: "Pause animation, button", then "Play animation, button".
- This pattern is mostly not a screen-reader concern. Its users are sighted people with vestibular, migraine, or attention conditions.
What the broken version does
The animation ignores prefers-reduced-motion entirely, runs infinitely at speed with a large translate-rotate-scale, and offers no way to pause it, which also fails SC 2.2.2 at Level A.
Source of the working demo
/* Define the animation, then neutralise it. Note that the element
still ENDS UP in the right state; it just gets there instantly.
Removing the animation must not remove the outcome. */
@keyframes slide-in {
from { transform: translateX(-2rem); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.panel { animation: slide-in 300ms ease-out; }
@media (prefers-reduced-motion: reduce) {
.panel { animation: none; } /* final state, no journey */
}
/* A global backstop for everything you forgot. 0.01ms rather than 0
so transitionend / animationend listeners still fire and nothing
hangs waiting for an event that never arrives. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Read it in JavaScript for things CSS cannot reach: canvas,
WebGL, an autoplaying video, a physics-based scroll library. */
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const reduced = mql.matches;
mql.addEventListener('change', (e) => setReduced(e.matches));
/* What "reduce" should actually mean, in order of preference:
1. Remove the movement, keep the outcome.
2. Cross-fade instead of translating or zooming.
3. Shorten it drastically.
What it must NOT mean: removing the feature.
The worst offenders are parallax, auto-playing carousels, and
large-area zoom or spin transitions, motion that fills the
viewport is far more provocative than a small one.
Also required regardless of the media query, at Level A:
SC 2.2.2 Pause, Stop, Hide, anything that moves automatically,
lasts more than 5 seconds, and sits alongside other content
needs a pause/stop/hide control.
SC 2.3.1 Three Flashes or Below Threshold, nothing may flash
more than three times per second. This one is a seizure risk,
not a comfort preference. */
Session timeouts that warn and extend
The problem: A silent session timeout punishes exactly the people who take longest: someone using a screen reader, a switch device, or voice input, someone re-reading a complicated question, someone who had to step away. They come back to an empty form and no explanation.
WCAG success criteria
- 2.2.1 Timing Adjustable (Level A, WCAG 2.0): The criterion that actually governs timeouts. A time limit must be able to be turned off, adjusted to 10× the default, or extended after a warning, with at least ten extensions available.
- 4.1.3 Status Messages (Level AA, WCAG 2.1): The warning has to reach a screen-reader user. role="alertdialog" both announces the message and takes focus, which is stronger than a plain live region for something requiring a decision.
- 2.2.6 Timeouts (Level AAA, WCAG 2.1): Users must be warned about the duration of any inactivity that could cause data loss, unless the data is preserved for more than 20 hours. Level AAA, so preserving the draft is best practice, not a conformance requirement.
Section 508
SC 2.2.1 Timing Adjustable is WCAG 2.0 Level A and is incorporated by E205.4, so a warn-and-extend flow is a genuine Section 508 requirement, one of the more commonly missed ones in agency applications. SC 4.1.3 and SC 2.2.6 are both WCAG 2.1 additions (and 2.2.6 is AAA on top of that), so neither is a 508 requirement. Functional Performance Criteria 302.7 With Limited Manipulation and 302.9 With Limited Language, Cognitive, and Learning Abilities are the ones that explain why the extra time matters: they describe the users for whom every interaction simply takes longer.
How to test with a keyboard
- Press Enter on "Start the session timer" and wait about 18 seconds without touching anything.
- In the accessible version a warning dialog appears and focus moves into it automatically.
- Press Escape: that counts as "keep working" and resets the clock.
- Start it again and let it run out. In the accessible version your draft survives; in the broken version the textarea is emptied without warning.
- Check that "Keep working" is reachable and operable without a mouse; a warning you cannot dismiss from the keyboard is no warning at all.
What a screen reader should announce
- Accessible: "Your session is about to end, alert dialog. You will be signed out in 12 seconds and your draft will be lost."
- The countdown itself must stay quiet. If your screen reader is reading every second, the timer has been wrongly marked aria-live="polite" or "assertive".
- Broken: silence, then an empty textarea. There is no announcement because there is nothing to announce.
What the broken version does
No warning is shown at all. The session simply expires and the textarea is cleared, destroying unsaved work with no announcement, no dialog, and no way to extend.
Source of the working demo
/* SC 2.2.1 Timing Adjustable (Level A) is satisfied if ANY ONE of
these is true:
• TURN OFF: the user can switch the limit off before meeting it
• ADJUST: the user can extend it to at least 10× the default
• EXTEND: the user is warned before it expires, told simply
how to extend (e.g. "press the space bar"), and
can extend at least 10 times
• REAL-TIME: the limit is essential to a real-time event
(an auction, a live exam)
• ESSENTIAL: extending would invalidate the activity
• 20 HOURS: the limit is longer than 20 hours
Session security limits are NOT automatically "essential". The
standard accessible answer is the warn-and-extend pattern below. */
const WARN_BEFORE_MS = 2 * 60 * 1000; // warn 2 minutes out
useEffect(() => {
const warn = setTimeout(() => setShowWarning(true),
SESSION_MS - WARN_BEFORE_MS);
const end = setTimeout(signOut, SESSION_MS);
return () => { clearTimeout(warn); clearTimeout(end); };
}, [sessionStartedAt]);
{showWarning && (
<div role="alertdialog" /* dialog + assertive announcement */
aria-modal="true"
aria-labelledby="to-title"
aria-describedby="to-desc"
ref={trapRef} /* focus trap; Esc = keep working */
tabindex="-1">
<h2 id="to-title">Your session is about to end</h2>
<p id="to-desc">
You will be signed out in 2 minutes and your draft will be lost.
</p>
<button onClick={extendSession}>Keep working</button>
<button onClick={signOut}>Sign out now</button>
</div>
)}
{/* The countdown itself must NOT be a live region. Announcing every
second makes the page unusable with a screen reader. role="timer"
is silent by default: announce only at the thresholds. */}
<p role="timer" aria-live="off">Session ends in {mm}:{ss}</p>
/* Two more rules that cost nothing:
• PRESERVE THE DATA. Draft to localStorage or the server. SC 2.2.5
Re-authenticating (AAA) asks for exactly this, and it is the
difference between an annoyance and a lost afternoon.
• WARN EARLY ENOUGH to be actionable. A screen-reader user needs
to hear the announcement, find the dialog, and read the options;
20 seconds is not enough.
/* Criterion numbering, stated correctly:
SC 2.2.1 Timing Adjustable A ← the timeout criterion
SC 2.2.3 No Timing AAA no time limits at all
SC 2.2.5 Re-authenticating AAA resume without data loss
SC 2.2.6 Timeouts AAA warn about data-loss limits
SC 1.4.13 Content on Hover/Focus AA tooltips and popovers,
NOT about session limits,
despite being frequently
cited that way. */
Speech input and Label in Name
The problem: Voice control activates a control by matching what you say against its accessible name. When a developer adds a "more descriptive" aria-label that replaces the visible text, the screen-reader announcement improves and the button becomes impossible to click by voice: an accessibility fix that breaks accessibility.
WCAG success criteria
- 2.5.3 Label in Name (Level A, WCAG 2.1): For components whose label includes visible text, the accessible name must contain that text. This is the criterion, and it exists specifically for speech input.
- 4.1.2 Name, Role, Value (Level A, WCAG 2.0): Voice control needs the name to exist at all before it can match against it; an unnamed icon button is unreachable by voice for the same reason it is unreachable by screen reader.
- 2.1.1 Keyboard (Level A, WCAG 2.0): Most speech-input software drives the page through simulated keyboard and pointer events, so anything that is not keyboard-operable is generally not voice-operable either.
Section 508
Be careful here. SC 2.5.3 Label in Name is a WCAG 2.1 addition and is therefore NOT incorporated by the 2017 Revised Section 508 Standards, which reference WCAG 2.0. What Section 508 does provide is Chapter 3 Functional Performance Criteria: 302.7 With Limited Manipulation and 302.8 With Limited Reach and Strength describe the users who rely on speech input, and 302.6 Without Speech covers the mirror case: an interface must not REQUIRE speech. So under 508 alone the argument is a functional-performance one; under WCAG 2.1 AA, EN 301 549, and the DOJ ADA Title II rule (2024), 2.5.3 applies directly.
How to test with a keyboard
- Read the "Visible label" and "Accessible name" columns side by side. Where the visible string does not appear inside the name, voice control cannot reach that control.
- If you have Windows Voice Access or macOS Voice Control, turn it on and try saying "click Save" against each variant. This is the only way to really feel it.
- Free proxy without any voice software: in browser devtools, open the Accessibility pane, select a button, and compare the Name field to the text you can see.
What a screen reader should announce
- Broken: "Submit the application form, button", while the screen shows "Save". A screen-reader user with partial vision hears one thing and reads another.
- Accessible: "Save the application form, button": the announcement is still richer than the visible text, and the visible text is still in there.
- The failure mode is silent in both directions: automated tools can flag it (axe has a "label-in-name" style rule), but only a human notices the mismatch is confusing.
What the broken version does
Each button carries an aria-label that replaces rather than extends its visible text, so the visible string does not appear in the accessible name at all. The check table below computes and reports the mismatch.
Source of the working demo
{/* ✅ Best: no aria-label. The visible text IS the accessible
name, so they cannot diverge. */}
<button>Previous step</button>
{/* ✅ Fine: the name STARTS WITH the visible text and adds context
for screen-reader users. "click Save" still works. */}
<button aria-label="Save the application form">Save</button>
{/* ❌ Broken: the name REPLACES the visible text. A voice-control
user saying "click Save" gets nothing; the word "Save" does not
appear in the accessible name at all. */}
<button aria-label="Submit the application form">Save</button>
{/* ❌ Also broken, and very common: an icon-and-text button where
someone labelled it for the icon. */}
<button aria-label="Navigate backwards">← Previous step</button>
{/* The same trap in forms: the placeholder is visible, the
aria-label is the name, and they disagree: */}
<input aria-label="Electronic mail address" placeholder="Email" />
{/* ✅ instead: a real <label> whose text is the name */}
<label for="email">Email</label><input id="email">
/* SC 2.5.3 Label in Name (Level A, WCAG 2.1)
"For user interface components with labels that include text or
images of text, the name contains the text that is presented
visually."
Practical reading:
• CONTAINS, not equals; you may add to it.
• Word order matters. Extra words at the FRONT are the usual
failure ("Search products" as the name for a button reading
"Search" is fine; "Product search" is not, because the
visible string "Search" is present but the leading words
break the match for most voice engines).
• Punctuation and case differences are tolerated.
• It applies to anything with a visible text label: buttons,
links, form fields, tabs, menu items.
Who this affects: voice-control users (Dragon, Windows Voice
Access, macOS and iOS Voice Control, Android Voice Access), and
also screen-reader users who can see the screen, hearing a name
that does not match what they are looking at is disorienting. */
Pre-launch checklist
49 checkable items, organised by success criterion. A practical working list, not a conformance audit and not legal advice.
1.1.1 Non-text Content (Level A, WCAG 2.0)
- Every informative image has alt text that conveys the information, not a description of the picture. (see images-icons)
- Every decorative image has alt="" present-and-empty (not a missing alt attribute), and decorative inline SVG has aria-hidden="true". (see images-icons)
- Images inside links or buttons have alt text describing the destination or action, not the picture. (see images-icons)
1.3.1 Info and Relationships (Level A, WCAG 2.0)
- Every visual heading is a real h1–h6 element, and levels are never skipped going down. (see landmarks)
- Every form control has a programmatically associated label: <label for> matching the control id. (see forms)
- Every data table has <th scope="col"> on column headers and <th scope="row"> on row headers, plus a <caption>. (see tables)
- Related radio buttons and checkboxes are grouped in a <fieldset> with a <legend>. (see forms)
1.3.2 Meaningful Sequence (Level A, WCAG 2.0)
- The DOM order matches the visual reading order. No CSS order/grid-area reordering that changes meaning, and no layout tables. (see tables)
1.3.5 Identify Input Purpose (Level AA, WCAG 2.1)
- Fields collecting information about the user carry the correct autocomplete token (name, email, tel, street-address, …). (see forms)
1.4.1 Use of Color (Level A, WCAG 2.0)
- No status, error, required-field marker, or chart series is distinguished by colour alone. Print a page in greyscale to check. (see colour-contrast)
- Links inside body text are distinguishable without colour: underlined, or 3:1 against the surrounding text plus a non-colour cue on hover and focus.
1.4.3 Contrast (Minimum) (Level AA, WCAG 2.0)
- All body text reaches 4.5:1 against its actual background, including text over images, gradients, and hover states. (see colour-contrast)
- Large text (24px, or 18.66px bold, and above) reaches 3:1. Placeholder text and helper text are not exempt. (see colour-contrast)
1.4.4 Resize Text (Level AA, WCAG 2.0)
- At 200% text size nothing is clipped or overlapped. No fixed height + overflow:hidden on text containers. (see reflow-zoom)
- The viewport meta tag does not set user-scalable=no or maximum-scale, and the root font size is not pinned in px. (see reflow-zoom)
1.4.5 Images of Text (Level AA, WCAG 2.0)
- No text is delivered as a picture of text, except logotypes. (see images-icons)
1.4.10 Reflow (Level AA, WCAG 2.1)
- At 320 CSS pixels wide (or 400% zoom on a 1280px window) there is no two-dimensional scrolling. Data tables and complex diagrams are exempt. (see reflow-zoom)
1.4.11 Non-text Contrast (Level AA, WCAG 2.1)
- Input borders, control boundaries, toggle states, and meaningful icons reach 3:1 against what is next to them. (see colour-contrast)
- The focus indicator itself reaches 3:1 against both the component and the page background; check it in both light and dark themes. (see focus-visible)
1.4.12 Text Spacing (Level AA, WCAG 2.1)
- With line-height 1.5, paragraph spacing 2×, letter-spacing 0.12em and word-spacing 0.16em forced by a user stylesheet, nothing is lost or overlapped. (see reflow-zoom)
1.4.13 Content on Hover or Focus (Level AA, WCAG 2.1)
- Tooltips and hover cards are dismissible with Escape, hoverable (the pointer can move onto them), and persist until dismissed or invalid.
2.1.1 Keyboard (Level A, WCAG 2.0)
- Every interactive element is reachable and operable by keyboard alone. No div-with-onClick controls. (see custom-controls)
- Anything that scrolls (code blocks, wide tables, map panes) is focusable so a keyboard user can scroll it.
- Nothing depends on hover alone; every hover interaction has a focus and a click equivalent.
2.1.2 No Keyboard Trap (Level A, WCAG 2.0)
- Focus can leave every component using standard keys. Modals close on Escape; embedded players and rich-text editors do not swallow Tab permanently. (see focus-trap)
2.2.1 Timing Adjustable (Level A, WCAG 2.0)
- Any session or interaction time limit can be turned off, extended to 10× the default, or extended after a warning (at least ten times). (see timeouts)
2.2.2 Pause, Stop, Hide (Level A, WCAG 2.0)
- Anything that moves, blinks, scrolls, or auto-updates for more than five seconds has a pause, stop, or hide control. Carousels are the usual offender. (see reduced-motion)
2.3.1 Three Flashes or Below Threshold (Level A, WCAG 2.0)
- Nothing flashes more than three times per second. This is a seizure-safety rule, not a comfort preference. (see reduced-motion)
2.4.1 Bypass Blocks (Level A, WCAG 2.0)
- A skip link is the first focusable element, becomes visible on focus, and moves focus (not just scroll) to a target with tabindex="-1". (see skip-link)
2.4.2 Page Titled (Level A, WCAG 2.0)
- Every page (and every client-side route) has a unique, descriptive <title> that names the view before the site.
2.4.3 Focus Order (Level A, WCAG 2.0)
- Tab order follows the visual order. No positive tabindex values anywhere. (see roving-tabindex)
- Opening a dialog moves focus into it; closing returns focus to the trigger. Client-side route changes move focus to the new page heading. (see focus-trap)
2.4.4 Link Purpose (In Context) (Level A, WCAG 2.0)
- No bare "click here" or "read more" links: the purpose is clear from the link text, or from the link plus its sentence, list item, or table cell.
2.4.5 Multiple Ways (Level AA, WCAG 2.0)
- There is more than one way to reach any page: navigation plus search, a sitemap, or an index.
2.4.6 Headings and Labels (Level AA, WCAG 2.0)
- Headings and labels describe the topic or purpose. No "Section 1" or "Enter text here". (see landmarks)
2.4.7 Focus Visible (Level AA, WCAG 2.0)
- Every focusable element shows a visible indicator. Search the whole codebase for "outline: none" and "outline: 0" and justify every hit. (see focus-visible)
2.5.3 Label in Name (Level A, WCAG 2.1)
- Every accessible name contains the control’s visible text. No aria-label that replaces rather than extends the visible label. (see speech-input)
2.5.4 Motion Actuation (Level A, WCAG 2.1)
- Nothing is triggered only by device motion (shake, tilt) without a conventional control alternative and a way to disable it.
3.1.1 Language of Page (Level A, WCAG 2.0)
- The <html> element has a correct lang attribute, so screen readers use the right pronunciation rules.
3.2.1 On Focus (Level A, WCAG 2.0)
- Nothing changes context purely because an element received focus: no auto-submitting, auto-navigating, or auto-opening on focus.
3.2.2 On Input (Level A, WCAG 2.0)
- Changing a setting does not automatically change context. A <select> that navigates on change fails unless the user was warned first.
3.3.1 Error Identification (Level A, WCAG 2.0)
- Errors are identified in text, associated with their field via aria-describedby, and the field is marked aria-invalid="true". (see forms)
3.3.2 Labels or Instructions (Level A, WCAG 2.0)
- Format requirements are stated before the user types, not only after they fail. Placeholders are never the only label. (see forms)
3.3.3 Error Suggestion (Level AA, WCAG 2.0)
- Where the fix is known, the message says what it is. "Enter a date in the form DD/MM/YYYY", not "Invalid date". (see forms)
3.3.4 Error Prevention (Legal, Financial, Data) (Level AA, WCAG 2.0)
- Submissions with legal or financial consequences are reversible, checked, or confirmed before they commit.
4.1.2 Name, Role, Value (Level A, WCAG 2.0)
- Every icon-only control has an accessible name, checked in the browser accessibility tree rather than assumed. (see accessible-name)
- Every custom widget exposes a role and keeps its state attributes (aria-expanded, aria-checked, aria-selected, aria-pressed) up to date. (see custom-controls)
- Every ARIA role that implies a keyboard model actually implements it: no role="toolbar" or role="tablist" without arrow keys. (see roving-tabindex)
4.1.3 Status Messages (Level AA, WCAG 2.1)
- Async results, validation summaries, filter counts, and toasts sit in a live region that was already in the DOM before the text changed. (see live-regions)