# A11Y Gate

> Statically verify new UI primitives for accessibility — animations gated behind prefers-reduced-motion, keyboard accessible, ARIA present — before declaring the primitive done. Use when authoring or modifying a UI primitive (component, page section, interactive widget), especially interactive/animated ones. Pairs well as a fill-the-wait task while builds run.

- Skill: `jcdavis131/a11y-gate` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jcdavis131/a11y-gate`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jcdavis131/a11y-gate/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: jcdavis131 (https://skillmd.com/u/jcdavis131)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/jcdavis131/a11y-gate

---


# 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:

```css
@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 `tabindex` only 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:

```tsx
<ol className="bh-feed" aria-live="polite" aria-label="Race log feed">
```

- **`aria-live="polite"`** — announce updates without interrupting the user (use `assertive` only for urgent alerts).
- **`aria-label`** — name the feed so the announcement is contextualized ("Race log feed: entry 42…").
- A feed without `aria-live` is 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.

