Skip to content

Foundation: Accessibility

FieldValue
TypeSkill Resource
Source~/.copilot/skills/design/references/foundation-a11y.md
DescriptionNot 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

ElementUseNever 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 itemsBare <div> rows
<table>Tabular data with headers<div className="table"> layout
<form>Any user-input collection<div className="form">
<label>Every form fieldPlaceholder-only fields
<fieldset> + <legend>Groups of related inputs (radio, checkbox)Unlabeled groups
<h1><h6>Content headingsBold <div> or <p> as headings
<figure> + <figcaption>Images/charts with captions<div><img /><p>
<time datetime="…">Dates and timesRaw date strings
<abbr title="…">Abbreviations on first useUnexplained 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 from h1 to h3.
  • 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 typeAA (minimum)AAA (target)
Normal text (< 18pt / < 14pt bold)4.5:17:1
Large text (≥ 18pt / ≥ 14pt bold)3:14.5:1
UI components and graphical objects3:13:1
Focus indicator3:1 (vs adjacent color)-

Design system contrast status (default light theme)

CombinationApprox ratioStatus
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.

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

CriterionSizeWCAG level
Minimum (AA, WCAG 2.2 §2.5.8)24×24pxAA
Recommended (AAA, §2.5.5)44×44pxAAA
  • 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-2 or p-3 on 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-motion override.
  • 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:

KeyExpected behavior
Tab / Shift+TabMove focus forward / backward
EnterActivate button, follow link, submit form
SpaceActivate button, toggle checkbox
Arrow keysMove within widgets (menu, listbox, tabs, radio group, slider)
EscapeClose modal, dismiss popover, cancel action
Home / EndJump 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 tabindex for 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:

SCLevelRule
2.4.11AAFocus indicator must be at least 2×2px area and 3:1 contrast vs adjacent colors
2.4.12AAAFocus indicator ≥ area of 2px perimeter outline of component
2.4.13AAAFocus indicator contrast ≥ 3:1
2.5.7AAEvery drag action must have a pointer alternative (click/tap)
2.5.8AATarget size ≥ 24×24px (with 24px spacing)
3.2.6AHelp mechanisms in consistent location across pages
3.3.7ADon’t require redundant data entry in same session
3.3.8AAAccessible authentication — no cognitive function tests
3.3.9AAANo 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-hidden or their parent has aria-label
  • Form fields have associated <label> or aria-label
  • Error messages are announced (role="alert" or aria-live="polite")
  • Modal/dialog traps focus and returns it on close
  • Images have meaningful alt or alt=""
  • 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
  • ADR-005 (testing accessibility release gates — jest-axe in CI)
  • ADR-009 (icon-text layout grid — icon aria-hidden pattern)