When Claude Code loads it
Use when building or reviewing any UI, component, landing page, form, modal, dropdown, navigation or interactive widget; when choosing colors or focus styles; when adding animation; when asked about accessibility, a11y, WCAG, ARIA, screen readers or keyboard navigation; or when `void a11y`, axe or Lighthouse report accessibility issues.
The target is WCAG 2.2 AA as the hard gate and a Lighthouse/axe score of 100, with APCA as the perceptual quality bar. Accessible markup also makes pages readable to AI agents and crawlers (see the seo skill).
Paste-ready components: references/patterns.md. Visual tokens and focus ring styling: the craft skill.
The 10 rules that matter most
- Use the native element.
navigates,acts,labels. Never putonClickon adivorspan. - Every interactive element is reachable and operable by keyboard in DOM order. No positive
tabindex. No keyboard traps except inside modal dialogs. - Visible focus everywhere:
:focus-visiblering 2px solid--line-focus, offset 2px, ≥ 3:1 against the surface. Never remove an outline without a replacement. - One
, one, headings in order, with landmarks,,. A skip link to#maincomes first in. - Contrast: body text ≥ 4.5:1 (APCA Lc 75+), large text ≥ 3:1, UI borders, icons and focus rings ≥ 3:1. Hover and focus states increase contrast, never decrease it.
- Hit targets ≥ 24×24 CSS px (WCAG 2.5.8), ≥ 44×44 on touch. Grow the hit area with a pseudo-element, not by changing the layout.
- Every image has
alt. Informative images get a description of their content; decorative ones getalt="". Icon-only buttons have an accessible name (aria-labelorsr-onlytext). - Forms: a visible
per field (a placeholder is not a label), correcttype/autocomplete, errors as text linked witharia-describedby+aria-invalid, and focus moves to the first invalid field on submit. - Dialogs, menus, comboboxes and tabs use a proven primitive (native
, Radix, Base UI, React Aria). Don't hand-roll focus traps. - Respect
prefers-reduced-motion. Nothing flashes more than 3×/s, and auto-moving content over 5 s has a pause control. Content never depends on an animation finishing.
Structure
// app/layout.tsx (inside <body>)
<a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-surface-raised focus:px-4 focus:py-2 focus:text-fg">
Skip to content
</a>
<header>…<nav aria-label="Primary">…</nav></header>
<main id="main">{children}</main> {/* modern browsers move the focus start point to the target; no tabIndex needed */}
<footer>…</footer>- Exactly one
. A layoutplus a pagegives two landmarks and a broken skip link. Pages render/inside it. - Multiple
s each get a distinctaria-label("Primary", "Footer", "Breadcrumb"). - Headings describe their section. Don't skip levels going down (h2 → h4). Don't pick heading levels for font size; style with tokens instead.
matches the content. Mark phrases in another language withlang.names the page and site ("Pricing | Acme"). Client route changes in Next update it automatically.- Lists are
/, tables arewith
, times are .- Links say where they go ("Read the pricing guide"), not "click here". Links that open a new tab say so (visible icon +
sr-only"(opens in new tab)").Keyboard and focus
css /* app/globals.css: base layer (Tailwind v4) */ @layer base { :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible { outline: 2px solid var(--line-focus); outline-offset: 2px; } :where(input, textarea, select):focus-visible { outline-offset: 0; } html { scroll-padding-top: calc(var(--header-h, 64px) + 16px); } /* sticky header never hides focused elements (2.4.11) */ }- Tailwind v4:
outline-nonenow literally setsoutline-style: none. Useoutline-hidden(which keeps a transparent outline for forced-colors mode) and always pair it with afocus-visible:ring. The utility form isfocus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus. - On a colored or brand surface, use the double ring:
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--line-focus). - Focus rings appear instantly (no transition), because focus feedback must not lag.
- Composite widgets (tabs, menus, radio groups, toolbars, listboxes, grids) use one tab stop + arrow keys (roving tabindex). Page navigation uses plain links, each a tab stop.
Escapecloses the topmost overlay and returns focus to its trigger.- Global shortcuts ignore keypresses inside inputs and editors. Single-key shortcuts must be remappable or disableable (2.1.4).
- Hide inert content with
inert(off-canvas menus, content behind non-dialog overlays), not just visually. display:none/hiddenremoves from both the a11y tree and tab order.aria-hidden="true"removes only from the a11y tree, so never put it on focusable content.
Hit targets and pointer
css .hit { position: relative; } .hit::before { content: ""; position: absolute; inset: -8px; } /* 24px icon → 40px target */ @media (pointer: coarse) { .hit::before { inset: -12px; } } /* ≥ 44px on touch */- Checkbox/radio + label are one hit area (the whole
row). - Adjacent small targets have ≥ 8 px gaps.
- Dragging (sliders, reorder, swipe) always has a click/tap alternative (2.5.7).
- Hover-only UI is forbidden. Anything revealed on hover also appears on focus and is reachable by touch.
touch-action: manipulationon controls removes the double-tap zoom delay.
Color and contrast (tie to tokens)
Content WCAG 2.2 (hard gate) APCA Lc (quality target) Token pairs to check Body text ≤ 18px ≥ 4.5:1 ≥ 75, 90 preferred --fgon--bg,--surface,--surface-raisedSecondary text, UI labels ≥ 4.5:1 ≥ 60 --fg-mutedon every surfaceLarge text (≥ 24px, or ≥ 18.66px bold) ≥ 3:1 ≥ 45 display sizes on --bgMeta, captions, placeholders ≥ 4.5:1 ≥ 60 --fg-subtle(AA on--bg,--bg-subtle,--surface,--surface-raised)Disabled controls, aria-hiddendecoration3:1 (disabled is exempt from 1.4.3) ≥ 30 --fg-faint— never text someone must readInput borders, focus ring, meaningful icons, chart marks ≥ 3:1 (1.4.11) ≥ 45–60 for icons --line-strong,--line-focusvs adjacent surfaceText on brand ≥ 4.5:1 ≥ 60 --fg-on-brandon--brandand--brand-hover;--brand-texton--bgStatus text ≥ 4.5:1 ≥ 60 --danger/--success/--warningtext on--bgand their-subtlefills- Check both light and dark themes. WCAG 2 overstates light-on-dark contrast, so APCA is stricter there.
- Never convey meaning by color alone. Errors get an icon + text, status gets a label, links in body text get an underline.
- Don't put
--fg-mutedgray text on colored fills. Use the fill's own text token. - Support
forced-colors: active(Windows High Contrast): don't rely onbackground-imageorbox-shadowalone for borders or focus. Outlines survive; shadows don't.
Forms
or a wrapping, visible and above the field. Required fields:required+ visible "(required)" text or a legend note.type="email|tel|url|number",inputMode,autoComplete="email|name|current-password|one-time-code"(1.3.5).spellCheck={false}for codes and emails.- Validate on blur and on submit, not on every keystroke. Once an error shows, re-validate on input.
- Error: text below the field with an icon;
aria-invalid="true";aria-describedby→ error id; focus the first invalid field on submit. For long forms, add an error summary with links. - Don't disable the submit button before the user tries. Let submit reveal what's missing. While submitting, keep the label ("Saving…") and set
aria-busy/disabledon the button. - Group radios and checkboxes in
+. - Never block paste. Allow password managers (3.3.8): no CAPTCHA-only logins, no copy-blocking on OTP fields.
- Field height ≥ 44 px on touch. Font size ≥ 16 px on inputs so iOS doesn't zoom.
Motion
- Every animation is wrapped by
@media (prefers-reduced-motion: no-preference), or neutralized underreduce. Seespeed/references/rendering-smoothness.md§2 and themotionskill. - Under reduced motion, replace movement with ≤ 150 ms opacity changes or nothing. Keep essential feedback (progress, state change).
- No parallax, zoom-on-scroll, large translations or auto-advancing carousels under
reduce. - Auto-playing video, marquees and carousels > 5 s get a visible pause button (2.2.2).
- Nothing flashes more than 3 times per second (2.3.1).
- Decorative animated text (scramble, typewriter) keeps the real text in
sr-onlyand marks the animated copyaria-hidden.
Overlays and widgets: choose, don't hand-roll
Need First choice Weight (gzip) Notes Disclosure / accordion /(usenamefor exclusive groups)0 KB Style summary; don't hide key SEO content in closed panelsModal dialog native +showModal()0 KB (tiny client leaf) Background becomes inert, Esc closes, focus returns to the opener. closedby="any"is Chromium/Firefox only, so add a backdrop click handlerNon-modal popover / simple menu of links popoverattribute +popovertarget0 KB Light-dismiss and Esc built in; Baseline 2025 Button opens dialog/popover without JS command/commandforinvokers0 KB Baseline newly available (Safari 26.2+); keep a JS fallback for older browsers Dropdown menu, context menu, select, combobox, date picker Radix ( radix-ui), Base UI (@base-ui/react), or React Aria ComponentsRadix Dialog ~12.6 KB, Tabs ~8.4 KB, Popover ~22.6 KB (includes Floating UI) Import per component. The full packages are large if not tree-shaken (Base UI ~147 KB, React Aria Components ~274 KB). Measure with void perfToasts sonner~9.4 KB Announces via a live region; toasts only for mutations Tabs Radix Tabs or the pattern in patterns.md0–8 KB Arrow keys, aria-selected,aria-controlsUse one primitive library per app. Vendored registry components (shadcn blocks, Magic UI, Aceternity, React Bits) often ship with no ARIA and no reduced-motion handling, so audit them before use.
Images, media, icons
altdescribes purpose and content in ≤ 125 chars. Don't start with "image of". Logos: the company name. Linked images: the link destination.- Charts: a text summary or data table next to the chart. Complex SVG:
role="img"+/aria-label. - Decorative SVG icons:
aria-hidden="true"(lucide does this by default). Icon-only buttons:.titlealone isn't enough. - Video: captions (
), no autoplay with sound, controls available. Audio-only content gets a transcript. - Canvas that conveys status:
role="img"+aria-label. Decorative canvas:aria-hidden="true".
Live regions and status
- Toast and async results:
role="status"(polite) for success and info;role="alert"only for errors that need immediate attention. - The live region must exist in the DOM before its text changes. Mount it empty at startup and update its text later.
- Loading:
aria-busy="true"on the region being updated. Spinners getsr-only"Loading…" text. - Don't announce on every keystroke. Debounce search result counts ("12 results").
- Status is never color-only: pair the dot with a text label (
role="status"where it updates).
Zoom, reflow, text spacing
- Never set
maximum-scale=1oruser-scalable=no. - Reflow at 320 CSS px width (≈ 400% zoom) with no horizontal scroll except for tables, code and maps (1.4.10). Test at 320px and at 200% browser zoom.
- Sizes in
remfor type. Display type usesclamp()so the h1 fits at 320px. - Tolerate user text spacing (1.4.12): no fixed heights on text containers. Use
min-height, andline-clamponly with a way to read the rest. overflow-x: clip(nothidden) onhtml/body, so sticky elements and focus scrolling keep working.100dvh/svhinstead of100vhfor full-height layouts on mobile.
Verify
void lint(static):lint/div-button,lint/missing-alt,lint/outline-none-no-replacement,lint/no-reduced-motion,lint/multiple-h1,lint/missing-lang.void a11y --start "next start -p 3000" --port 3000 --format mdon a production build: axe-core (WCAG 2.2 AA + best practices) on mobile and desktop, plus void's own checksa11y/focus-not-visible(keyboard focus walk),a11y/target-size(mobile),a11y/reflow-overflow(320px). Common axe ids:a11y/color-contrast,a11y/button-name,a11y/link-name,a11y/label,a11y/image-alt,a11y/heading-order,a11y/landmark-one-main,a11y/bypass,a11y/meta-viewport,a11y/aria-hidden-focus,a11y/nested-interactive,a11y/link-in-text-block. Unmapped axe rules arrive asa11y/axe-otherwith the axe id in the message.- Stop when: zero axe violations, the focus walk passes, a11y score 100. Then do a 2-minute manual pass: Tab through the page, open and close every overlay with the keyboard, and use VoiceOver (macOS: Cmd+F5) or NVDA on the main flow.
- Automated tools catch only part of real issues. The manual pass is not optional for dialogs, menus and forms.
- Links say where they go ("Read the pricing guide"), not "click here". Links that open a new tab say so (visible icon +