Foundation: Accessibility
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/design/references/foundation-a11y.md |
| Description | Not specified |
Source Content
Foundation: Accessibility
Standard: WCAG 2.2 AAA (target) / WCAG 2.2 AA (minimum floor — no exceptions).
WCAG 2.2 is the current stable standard (September 2023). WCAG 3.0 is draft — do not rely on it yet.
Semantic HTML — use the right element
Never replace a semantic HTML element with a styled <div> or <span>. The element IS the semantics — screen readers, voice control, and keyboard navigation all depend on it.
Elements and when to use them
| Element | Use | Never do |
|---|---|---|
<button> | Any clickable action | <div onClick> or <span onClick> |
<a href> | Navigation to a URL | <div onClick={() => navigate(…)}> |
<nav> | Site/section navigation | <div className="nav"> |
<main> | Primary page content — one per page | <div id="main"> |
<header> | Page or section header | <div className="header"> |
<footer> | Page or section footer | <div className="footer"> |
<aside> | Related secondary content | <div className="sidebar"> |
<section aria-labelledby="id"> | Thematic section with visible heading | <div className="section"> |
<article> | Self-contained content (card, post, comment) | <div className="article"> |
<ul> / <ol> / <li> | Lists of items | Bare <div> rows |
<table> | Tabular data with headers | <div className="table"> layout |
<form> | Any user-input collection | <div className="form"> |
<label> | Every form field | Placeholder-only fields |
<fieldset> + <legend> | Groups of related inputs (radio, checkbox) | Unlabeled groups |
<h1>–<h6> | Content headings | Bold <div> or <p> as headings |
<figure> + <figcaption> | Images/charts with captions | <div><img /><p> |
<time datetime="…"> | Dates and times | Raw date strings |
<abbr title="…"> | Abbreviations on first use | Unexplained abbreviations |
<dialog> | Modal dialogs | <div role="dialog"> (unless polyfilling) |
Heading hierarchy
- One
<h1>per page — the page title. - Never skip levels:
h1 → h2 → h3. Do not jump fromh1toh3. - Headings are document structure, not font size. Use CSS for size.
Forms
Every <input>, <select>, and <textarea> must have an associated <label>:
{/* Visible label */}<label htmlFor="email">Email address</label><input id="email" type="email" />
{/* Screen-reader only label */}<label htmlFor="search" className="sr-only">Search</label><input id="search" type="search" placeholder="Search…" />
{/* aria-label when no label element is possible */}<input type="search" aria-label="Search documents" />Never use placeholder as the only label — placeholders disappear and are low contrast.
Tables
<table> <caption>Monthly income by category</caption> <thead> <tr> <th scope="col">Category</th> <th scope="col">Amount</th> </tr> </thead> <tbody> <tr> <th scope="row">Salary</th> <td>$4,500.00</td> </tr> </tbody></table>Color contrast
WCAG 2.2 contrast ratios
| Text type | AA (minimum) | AAA (target) |
|---|---|---|
| Normal text (< 18pt / < 14pt bold) | 4.5:1 | 7:1 |
| Large text (≥ 18pt / ≥ 14pt bold) | 3:1 | 4.5:1 |
| UI components and graphical objects | 3:1 | 3:1 |
| Focus indicator | 3:1 (vs adjacent color) | - |
Design system contrast status (default light theme)
| Combination | Approx ratio | Status |
|---|---|---|
text-foreground on bg-background | ~14:1 | ✅ AAA |
text-foreground on bg-card | ~14:1 | ✅ AAA |
text-muted-foreground on bg-card | ~5.5:1 | ✅ AA |
text-primary on bg-background | ~4.8:1 | ✅ AA |
text-primary-foreground on bg-primary | ~7:1 | ✅ AAA |
text-destructive-foreground on bg-destructive | >4.5:1 | ✅ AA |
Never use text-muted-foreground for primary interactive labels — only for supporting/secondary text.
Never use color alone
Color must never be the only means of conveying information:
- Form errors: color + icon + text message (not just a red border)
- Status: color + text label or icon (not just a colored dot)
- Required fields:
*label +aria-required="true"(not just a red asterisk)
Focus management
Visible focus — non-negotiable
Every interactive element must have a visible, WCAG-compliant focus indicator:
{/* Standard focus ring — use on all interactive elements */}className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
{/* Inside dark surfaces */}className="focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-surface-strong"
{/* Inset ring for elements where offset would overlap layout */}className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"Never outline-none without providing an alternative focus style. Never outline: none in CSS without a replacement.
Focus trap (modals/dialogs)
- When a modal opens, move focus to the first interactive element inside it.
- Tab must cycle only within the modal while it is open.
- When the modal closes, return focus to the trigger element.
- Use Radix UI / React Aria — do not implement focus trap from scratch.
Skip links
Every page must have a skip link as the first focusable element:
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-lg focus:bg-background focus:px-4 focus:py-2 focus:ring-2 focus:ring-ring"> Skip to main content</a>Touch targets
| Criterion | Size | WCAG level |
|---|---|---|
| Minimum (AA, WCAG 2.2 §2.5.8) | 24×24px | AA |
| Recommended (AAA, §2.5.5) | 44×44px | AAA |
- All buttons, links, checkboxes, radios, and toggle targets must meet 44×44px.
- Use
min-h-11 min-w-11(44px) on interactive elements. - If a smaller visual target is needed, add invisible padding:
p-2orp-3on the trigger. - Do not pack interactive elements so close that the spacing between targets is < 24px.
ARIA — use sparingly, correctly
The first rule of ARIA: don’t use ARIA when a native HTML element already provides the semantics.
When ARIA is needed
{/* Announce dynamic updates */}<div role="status" aria-live="polite" aria-atomic="true"> {message}</div>
{/* Error message linked to a field */}<input aria-describedby="email-error" aria-invalid="true" /><p id="email-error" role="alert">{errorMessage}</p>
{/* Icon-only button */}<button aria-label="Close dialog"> <X aria-hidden="true" /></button>
{/* Decorative icon alongside text */}<CheckCircle aria-hidden="true" /><span>Saved</span>
{/* Loading state */}<div aria-busy="true" aria-label="Loading results"> <Spinner aria-hidden="true" /></div>
{/* Expanded/collapsed state */}<button aria-expanded={open} aria-controls="panel-id">Toggle</button><div id="panel-id" hidden={!open}>…</div>
{/* Current page in nav */}<a aria-current="page" href="/dashboard">Dashboard</a>Icons
- Decorative icons: always
aria-hidden="true"— screen readers skip them. - Meaningful icons (no adjacent text label): parent button/link must have
aria-label. - Never rely on icon alone without text or
aria-label.
Images and media
{/* Meaningful image */}<img src="…" alt="Judge's signature block on a Virginia court order" />
{/* Decorative image */}<img src="…" alt="" role="presentation" />
{/* Complex image (charts, diagrams) */}<figure> <img src="chart.png" alt="" aria-describedby="chart-desc" /> <figcaption id="chart-desc"> Bar chart showing monthly income: January $4,500, February $4,800… </figcaption></figure>
{/* Background image with text content */}{/* Always use foreground <img> or CSS only if purely decorative */}Video / audio
- Captions for all video with audio (AA).
- Audio descriptions for video where visuals carry meaning (AAA).
- No autoplay with audio — always provide pause/stop control.
Motion and animation
{/* Already handled by the design system motion tokens */}@media (prefers-reduced-motion: reduce) { /* all --motion-* durations halve automatically */ /* translate/scale transforms become opacity-only */ /* looping animations stop */}- Never animate anything that causes vestibular issues (rapid flashing, large spinning, parallax) without a
prefers-reduced-motionoverride. - No content may flash more than 3 times per second (seizure threshold — WCAG 2.3.1 A).
- Provide pause controls for any animation that plays for more than 5 seconds.
Keyboard navigation
All functionality must be operable by keyboard alone:
| Key | Expected behavior |
|---|---|
Tab / Shift+Tab | Move focus forward / backward |
Enter | Activate button, follow link, submit form |
Space | Activate button, toggle checkbox |
Arrow keys | Move within widgets (menu, listbox, tabs, radio group, slider) |
Escape | Close modal, dismiss popover, cancel action |
Home / End | Jump to first/last item in a list widget |
- Tab order must follow reading order (left-to-right, top-to-bottom).
- No keyboard traps — always a way to escape interactive widgets.
- Roving
tabindexfor composite widgets (tabs, toolbars, menus).
Language and reading
<html lang="en"> <!-- Required on every page --><html lang="en-US"> <!-- Preferred — locale-specific -->Inline language changes:
<span lang="es">en español</span>WCAG 2.2 new criteria (not in 2.1)
These were added in WCAG 2.2 — easy to miss:
| SC | Level | Rule |
|---|---|---|
| 2.4.11 | AA | Focus indicator must be at least 2×2px area and 3:1 contrast vs adjacent colors |
| 2.4.12 | AAA | Focus indicator ≥ area of 2px perimeter outline of component |
| 2.4.13 | AAA | Focus indicator contrast ≥ 3:1 |
| 2.5.7 | AA | Every drag action must have a pointer alternative (click/tap) |
| 2.5.8 | AA | Target size ≥ 24×24px (with 24px spacing) |
| 3.2.6 | A | Help mechanisms in consistent location across pages |
| 3.3.7 | A | Don’t require redundant data entry in same session |
| 3.3.8 | AA | Accessible authentication — no cognitive function tests |
| 3.3.9 | AAA | No exception — accessible auth applies to all steps |
Screen reader testing checklist
Before shipping any new component:
- Tab through — every interactive element is reachable and labeled
- All icons are either
aria-hiddenor their parent hasaria-label - Form fields have associated
<label>oraria-label - Error messages are announced (
role="alert"oraria-live="polite") - Modal/dialog traps focus and returns it on close
- Images have meaningful
altoralt="" - Heading hierarchy is correct (h1 → h2 → h3)
-
<html lang="en">is set - Skip link exists on page entry
Tools
jest-axe— automated a11y in unit tests (CI gate)@storybook/addon-a11y— per-story a11y panel in Storybook- VoiceOver (macOS): Cmd+F5 to enable, navigate with VO+arrow
- NVDA (Windows, free): most common screen reader
- axe DevTools browser extension — manual audit
- Colour Contrast Analyser — pick any two colors, get ratio
Related ADRs
- ADR-005 (testing accessibility release gates — jest-axe in CI)
- ADR-009 (icon-text layout grid — icon aria-hidden pattern)