A11y Gate
New UI primitives can ship with unguarded animations, missing keyboard support, or absent ARIA. A static a11y check on each new primitive catches these before they ship. It's cheap, and it's a perfect fill-the-wait task while builds run.
The three checks
For every new UI primitive, statically verify:
1. Animations gated behind prefers-reduced-motion
Any animation (transitions, keyframes, motion components) must respect the user's reduced-motion preference:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Or, with a motion library, gate the animation on a reduced-motion check. An ungated animation is a vestibular issue for some users — not a cosmetic nicety.
2. Keyboard accessible
Every interactive element is reachable and operable by keyboard:
- Focusable elements have a visible focus indicator.
- Interactive widgets (menus, dialogs, tabs, feeds) have arrow-key + enter/space + escape handling.
- No keyboard traps (focus must be able to leave the widget).
- Tab order is logical (DOM order, or
tabindexonly when necessary).
3. ARIA present and correct
- Interactive widgets have the right ARIA role (
role="dialog",role="tablist", etc.). - State is exposed (
aria-expanded,aria-selected,aria-busy). - Labels are present (
aria-label,aria-labelledby, or visible label associated). - Don't over-ARIA — if a native element conveys the semantics, use the native element and skip the ARIA.
How to run it
A static check (no full screen-reader run) — read the primitive's JSX/CSS and confirm the three things:
a11y gate — <Primitive>:
[x] animations gated (prefers-reduced-motion)
[x] keyboard: focusable + visible focus + no traps + arrow/enter/esc on widgets
[x] ARIA: roles + state + labels, no over-ARIA
Report the result as a one-line verdict. If any check fails, fix before marking the primitive done.
When to run
- After authoring a new UI primitive (component, page section, widget).
- After modifying an interactive or animated primitive.
- As a fill-the-wait task while builds run — it's static, no network, no build dependency.
When NOT to run
- Pure static/display primitives with no interaction or animation — the three checks are vacuous.
- A primitive already covered by an automated a11y test in the suite — let the suite handle it.
Dynamic feeds use aria-live
A feed or list that updates over time (a race log, a run feed, a live event stream) needs aria-live so screen readers announce updates:
<ol className="bh-feed" aria-live="polite" aria-label="Race log feed">
aria-live="polite"— announce updates without interrupting the user (useassertiveonly for urgent alerts).aria-label— name the feed so the announcement is contextualized ("Race log feed: entry 42…").- A feed without
aria-liveis silent to screen-reader users even though it's visually updating — the dynamic content is invisible to them.
Anti-patterns
- "We'll do an a11y pass later." Later never comes; the primitive ships unguarded.
- Ungated animations "because they're subtle". Subtle to you is not subtle to a vestibular user.
- Over-ARIA. Adding
role="button"to a<button>is noise; use the native element. - Keyboard support added "in a follow-up". A widget that's mouse-only on ship is a regression.
- No visible focus indicator. Keyboard users can't see where they are.
Pair with
validate-gate— the a11y gate is the frontend-specific gate alongside typecheck/lint.fill-the-wait— run the a11y gate while builds run; it's static and independent.use-design-system— shared primitives must clear the a11y gate once, then every consumer inherits it.