11 KiB
Accessibility — Craft, Not Compliance
Accessibility is where amateurs stop and pros begin — it is
SKILL.mdprinciple 7 applied to people. It is also the fastest way to tell real craft from generated output: slop pages are keyboard-hostile, unlabeled, and focus-invisible. The floor is WCAG 2.2 AA. The target is: nobody can tell this page was built by an LLM, including someone using a screen reader.
The Mental Model
Accessibility is three habits, not a checklist bolted on at the end:
- Robust structure — semantic HTML that means what it says.
- Visible states — focus, hover, error, disabled (already required by
components.md). - Respect — for motion sensitivity, zoom, touch, and slow connections.
If you build with these from Step 1 (see SKILL.md process), accessibility costs almost nothing extra. If you bolt it on at Step 10, it costs a rewrite.
Semantic HTML First
Landmarks, one of each where it matters
<header> <!-- site masthead -->
<nav aria-label="Primary"> <!-- main navigation -->
<main id="main"> <!-- THE one per page -->
<section aria-labelledby="features-title">
<aside> <!-- truly tangential content -->
<footer> <!-- colophon -->
Heading order
- One
<h1>per page — the page's claim. - Never skip levels downward (
h2→h4). Headings are the screen reader's table of contents. - The visual hierarchy and the heading hierarchy must match. If a kicker looks bigger than the
h2, fix the CSS, not the outline.
Button or link? (decide correctly, agents get this wrong constantly)
| It does this | Use |
|---|---|
| Goes somewhere (URL changes) | <a href="..."> |
| Does something (opens, submits, toggles, copies) | <button> |
| Submits a form | <button type="submit"> |
| Toggles a menu that navigates | <a> styled as a control — not a <div onclick> |
A <div> with a click handler is not a button. No exceptions.
Lists are lists
Indexes, catalogs, feature lists, nav items: use <ol>/<ul>/<li>. Screen readers announce "list, 8 items" — that announcement is design.
Keyboard
- Tab order = DOM order = visual order. If they diverge, restructure the DOM — never "fix" it with
tabindexabove 0. tabindex="0"only for genuinely focusable custom components (a custom tab, a combobox — before you build one, check if a native element works).- Skip link on any page longer than one screen:
<a class="skip-link" href="#main">Skip to content</a>
.skip-link {
position: absolute; left: var(--sp-4); top: var(--sp-4);
transform: translateY(-200%);
/* visible + on-brand when focused */
}
.skip-link:focus-visible { transform: none; outline: 2px solid var(--accent); }
Key contracts
| Component | Keys |
|---|---|
| Buttons | Enter, Space |
| Links | Enter |
| Dialog / modal | Escape closes; focus trapped inside; focus returns to trigger on close |
| Menu / listbox | Arrow Up/Down, Home/End, Escape |
| Tabs | Arrow Left/Right between tabs, Home/End |
| Combobox / ⌘K palette | Arrow Up/Down, Enter selects, Escape closes — see product-ui-patterns.md §2 |
| Dismissible toast | Escape or timed auto-dismiss |
Test the whole page with the keyboard alone. If you can't reach it, click it, and dismiss it — it doesn't ship.
Focus — Design It, Don't Delete It
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* apply to everything interactive: a, button, input, select, textarea, [tabindex] */
a:focus-visible, button:focus-visible, input:focus-visible,
select:focus-visible, textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
Rules:
:focus-visiblefor mouse-dominant UI is correct; but keyboard focus must always show.- Never
outline: nonewithout an equal-or-better replacement visible on keyboard use. - Focus ring contrast: ≥ 3:1 against both the element and its background.
- After route/content changes, move focus deliberately — to the new page's
h1(tabindex="-1"+.focus()) or the dialog. A screen reader left reading stale content is a broken page.
Forms
- Every input has a visible, persistent
<label>. Placeholder is not a label — it disappears on input and fails low-vision users. - Group related inputs with
<fieldset>+<legend>(plan selection, address blocks). - Errors: name the problem and the fix, linked programmatically:
<label for="email">Work email</label>
<input id="email" type="email" aria-describedby="email-error" aria-invalid="true">
<p id="email-error" class="field-error">
Enter your work email — we'll send the invoice there.
</p>
- Use
autocomplete="email",autocomplete="cc-number", etc. — they are free conversion wins. - Mark required in text (
*only if you also explain it). Never rely on color alone — pair it with a word or icon. - Inputs at
16px+ font size to prevent mobile Safari auto-zoom.
ARIA — Less Is More
First rule of ARIA: don't use ARIA if a native element exists. A <button> needs zero ARIA. A <div role="button" tabindex="0"> needs four attributes and still works worse.
| Need | Native first | ARIA only if you must |
|---|---|---|
| Clickable action | <button> |
role="button" + tabindex="0" + Enter/Space handlers |
| Expand/collapse | <details>/<summary> |
aria-expanded on trigger, aria-controls |
| Current page in nav | class + link styling | aria-current="page" |
| Icon-only button | — | aria-label="Close menu" |
| Live announcement | — | aria-live="polite" region |
| Dialog | <dialog> |
role="dialog" + aria-modal="true" + focus trap |
The four ARIA attributes worth knowing cold
aria-label— only on interactive elements with no visible text (icon buttons, close buttons).aria-expanded— on disclosure triggers (menu, accordion, ⌘K).aria-current="page"— on the active nav item.aria-hidden="true"— on decorative duplicates (icon next to a text label, CSS artwork).
Never both aria-hidden and focusable on the same element. Never role="presentation" on a table that holds data.
Color & Contrast Beyond Body Text
- Body text ≥ 4.5:1, large display ≥ 3:1 (details in
typography.md/color.md). - Non-text contrast: icons, input borders, focus rings, chart lines — ≥ 3:1 against their background. The
#E5E5E5hairline on white fails for input borders; use it for dividers only,#9B9B9B+ for interactive outlines. - Color is never the only signal. Errors need text, statuses need labels or shapes (●/▲/■ — see
product-ui-patterns.md§5), links need underline or weight, not hue alone. - Test both themes — dark mode accent often needs a lighter variant (
color.md§Dark Mode).
Images & Media
The alt decision tree:
| Image | Alt |
|---|---|
| Decorative (CSS art, texture, divider) | alt="" + it's probably CSS, not <img> |
| Informative (photo of the product) | Describe what the user needs to know: "Latch dashboard with three flag rows, all toggled on" |
| Functional (image is a link/button) | Describe the action: "View issue 14" |
| Complex (chart, diagram) | Short alt + the data in adjacent text/table |
- No autoplaying audio, ever. Video: captions on, pause control reachable by keyboard.
alttext is copy — write it like copy (content.md), not like a filename."IMG_2841.jpg"is slop.
Motion & Vestibular Safety
Full system in motion.md. The accessibility floor:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
- No parallax, no scroll-jacking, no autoplaying carousels without a pause — with or without the media query honored.
- Nothing flashes more than 3 times per second.
- Motion is never the only way information is conveyed.
Touch & Zoom
- Touch targets ≥ 44×44px (36px minimum where space is genuinely scarce, with ≥ 8px between targets).
- Do not disable pinch zoom:
content="width=device-width, initial-scale=1"— nomaximum-scale, nouser-scalable=no. - Respect
100%–200%zoom and320pxwidth without horizontal scroll (also inlayout.mdQA). - Gestures need single-pointer alternatives — swipe is a bonus, not a requirement.
Announcing Dynamic Changes
Agents build UIs that change silently. Screen readers must hear what changed:
| Change | Mechanism |
|---|---|
| Toast / saved state | aria-live="polite" region, always in the DOM, text swapped in |
| Form errors on submit | aria-live or move focus to the error summary |
| Search results count | Announce "12 results" politely |
| Route change (SPA) | Move focus to new h1 (tabindex="-1") |
| Critical failure | role="alert" (assertive) — use at most once per page |
<div class="sr-only" aria-live="polite" id="live-status"></div>
The Testing Protocol (15 minutes, before every ship)
- Keyboard pass: unplug the mouse. Tab through everything. Reachable? Visible? Dismissible? Logical order?
- Screen reader pass: VoiceOver (Mac: Cmd+F5) or NVDA (free, Windows). Navigate by headings and landmarks. Does the outline make sense?
- Contrast audit: run axe DevTools or Lighthouse — zero violations, not "close enough."
- Zoom pass: 200% browser zoom at 1280px — no clipped content, no horizontal scroll.
- Grayscale pass: can you still tell error from success, primary from secondary?
Accessibility Anti-Patterns
| ❌ Don't | ✅ Do |
|---|---|
<div onclick> controls |
<button> / <a href> |
outline: none with no replacement |
Designed :focus-visible on everything interactive |
| Placeholder as label | Persistent <label>, placeholder as example |
aria-label on non-interactive elements |
Visible text or sr-only text |
| Icon-only buttons with no name | aria-label="Search" |
| Headings chosen by visual size | One h1, ordered outline, CSS handles size |
| Color as the only error/status signal | Text + color, shape + color |
| Autoplay carousel, no pause | Static content or user-driven with pause |
user-scalable=no in viewport meta |
Leave zoom alone |
| Modals that don't trap or return focus | Trap inside, return to trigger, Escape closes |
| Live changes nobody announces | aria-live status region |
| Accessibility "added later" | Semantics from the first tag written |
Ship Gate
- Keyboard pass complete — every control reachable, visible, dismissible
- One
h1, ordered headings, landmarks present - All inputs labeled; errors linked and actionable
:focus-visibledesigned, never removed- Contrast AA on text and 3:1 on interactive outlines, both themes
prefers-reduced-motionhonored- Alt text on every meaningful image; decorative marked empty
- Dynamic changes announced; focus managed on dialogs and routes
Zero known violations. Not "minor issues" — zero. See checklist.md §Accessibility for the pre-ship list.