hermes-hub/.agents/skills/frontend-design/accessibility.md

11 KiB
Raw Permalink Blame History

Accessibility — Craft, Not Compliance

Accessibility is where amateurs stop and pros begin — it is SKILL.md principle 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:

  1. Robust structure — semantic HTML that means what it says.
  2. Visible states — focus, hover, error, disabled (already required by components.md).
  3. 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 (h2h4). 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.
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 tabindex above 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-visible for mouse-dominant UI is correct; but keyboard focus must always show.
  • Never outline: none without 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-labelonly 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 #E5E5E5 hairline 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.
  • alt text 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" — no maximum-scale, no user-scalable=no.
  • Respect 100%200% zoom and 320px width without horizontal scroll (also in layout.md QA).
  • 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)

  1. Keyboard pass: unplug the mouse. Tab through everything. Reachable? Visible? Dismissible? Logical order?
  2. Screen reader pass: VoiceOver (Mac: Cmd+F5) or NVDA (free, Windows). Navigate by headings and landmarks. Does the outline make sense?
  3. Contrast audit: run axe DevTools or Lighthouse — zero violations, not "close enough."
  4. Zoom pass: 200% browser zoom at 1280px — no clipped content, no horizontal scroll.
  5. 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-visible designed, never removed
  • Contrast AA on text and 3:1 on interactive outlines, both themes
  • prefers-reduced-motion honored
  • 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.