Home Benchmarks Learn Tools News
SPONSOR

AppSignal — Stop vibe-debugging. Every exception, every backtrace, grouped so you see patterns, not noise.

↗
Skills · Accessibility
Front-end skill · v2 · WCAG 2.2 AA

Code everyone can use.

Runs inside your agent. Enforces WCAG 2.2 AA: keyboard, focus, contrast, screen reader patterns, accessible forms.

Install the skill → Read SKILL.md
10
Enforced patterns
6
AI tools supported
0
Dependencies
agent — frontend-accessibility live
$ agent write --skill frontend-accessibility Form.tsx
▼ generating Form.tsx with skill loaded
keyboard nav verified (Tab, Esc) PASS
focus management on dialog PASS
4.5:1 contrast on tokens PASS
aria-describedby links errors PASS
aria-live=polite for status PASS
:focus-visible styles present PASS
prefers-reduced-motion respected PASS
written Form.tsx · WCAG 2.2 AA · 0 violations A
→ ready to ship. open in editor? [Y/n]
Works with
  • Cursor
  • Claude Code
  • Codex CLI
  • Windsurf
  • GitHub Copilot
  • Gemini CLI
What it enforces

WCAG 2.2 AA, in production-grade form.

accessibility / patterns SKILL.md · WCAG 2.2 AA · native first
patterns/
keyboard.diff •
−SaveButton.tsx Before
<!-- not focusable, no Enter/Space, no disabled state -->
<div role="button" onClick={save}>
Save
</div>
 
// then you bolt this on, badly
div.addEventListener('keydown', e => {
if (e.key === 'Enter') save();
});
+SaveButton.tsx After
<button type="submit" onClick={save}>
Save
</button>
 
/* and skip-to-content is the first stop */
<a href="#main" class="skip-link">
Skip to main content
</a>
− no Tab, no Enter/Space · bolted-on key handlers → + full keyboard support · native, free
focus.diff •
−focus.css Before
/* the keyboard user is now flying blind */
button:focus,
input:focus,
a:focus {
outline: none;
}
 
/* dialog opens, focus stays on the trigger */
<div role="dialog" aria-modal="true">
<!-- no focus trap, Tab leaks to the page behind -->
</div>
+focus.css After
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
 
/* native <dialog> traps focus + restores it */
<dialog id="confirm">...</dialog>
<script>
document.querySelector('#confirm').showModal();
</script>
− outline:none · focus leaks behind dialogs → + :focus-visible ring · native focus trap + restore
contrast.diff •
−colors.css Before
.muted { color: #a1a1aa; } /* 2.9:1 fail */
.input { border: 1px solid #e5e5e5; } /* 1.4:1 fail */
.error { color: #ff6b6b; } /* 3.2:1 fail */
 
/* "the red is the error" — only the red */
<span class="text-red">Required</span>
+colors.css After
.muted { color: var(--text-muted); } /* 4.6:1 */
.input { border: 1px solid var(--border-strong); } /* 3.1:1 */
.error { color: var(--color-error-on-bg); } /* 4.7:1 */
 
<!-- icon + text, never color alone -->
<span class="error">
<svg aria-hidden="true">...</svg> Required
</span>
− 2.9:1 / 1.4:1 / 3.2:1 · color alone → + 4.5:1+ tokens · non-color cues
screen-reader.diff •
−form.tsx Before
<!-- placeholder vanishes when typing -->
<input type="email" placeholder="Email">
 
<!-- error not associated with the field -->
<p class="err">Email is required</p>
 
<!-- icon button, no name -->
<button><svg>...</svg></button>
+form.tsx After
<label for="email">Email</label>
<input id="email" type="email"
aria-invalid="true"
aria-describedby="email-err">
<p id="email-err" role="alert">Email is required</p>
 
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
 
<div role="status" aria-live="polite">Saved.</div>
− placeholder-as-label · orphaned errors · nameless icons → + labels · describedby · alerts · live regions
Benchmarked

Proof, not vibes.

Pricing Section brief · Claude Opus 4.7 · with skill vs. without · 2026-04-21
100
Lighthouse Accessibility 78 → 100 baseline +22
0
WCAG violations 6 → 0 baseline −6
9
Tokens > 4.5:1 Avg per audited surface
94
A11y grade · A vs C · 71 baseline +23
0–49 50–89 90–100
Single-run comparison. Same model and prompt, scored on Lighthouse + an internal rubric.
Install

One file. Six tools. Zero ceremony.

One Markdown file, zero dependencies. Pick your tool below.

1Drop this in

Project: .cursor/skills/frontend-accessibility.md

2Or fetch it directly
curl -fsSL https://webdeveloper.com/skills/frontend-accessibility/SKILL.md -o .cursor/skills/frontend-accessibility.md

Restart Cursor. Every interface the agent writes from now on enforces WCAG 2.2 AA and avoids the listed anti-patterns.

1Drop this in

User-level: ~/.claude/skills/frontend-accessibility/SKILL.md

2Or fetch it directly
mkdir -p ~/.claude/skills/frontend-accessibility && curl -fsSL https://webdeveloper.com/skills/frontend-accessibility/SKILL.md -o ~/.claude/skills/frontend-accessibility/SKILL.md

Claude Code auto-discovers skills in ~/.claude/skills/. Available across every project on this machine.

1Drop this in

Project: AGENTS.md (append the SKILL contents)

2Or fetch it directly
curl -fsSL https://webdeveloper.com/skills/frontend-accessibility/SKILL.md >> AGENTS.md

Codex CLI reads AGENTS.md automatically when you run it from the project root.

1Drop this in

Project: .windsurf/rules/frontend-accessibility.md

2Or fetch it directly
mkdir -p .windsurf/rules && curl -fsSL https://webdeveloper.com/skills/frontend-accessibility/SKILL.md -o .windsurf/rules/frontend-accessibility.md

Windsurf loads project rules on every Cascade run.

1Drop this in

Project: .github/copilot-instructions.md (append)

2Or fetch it directly
mkdir -p .github && curl -fsSL https://webdeveloper.com/skills/frontend-accessibility/SKILL.md >> .github/copilot-instructions.md

Copilot reads .github/copilot-instructions.md as project-wide context.

1Drop this in

Project: .gemini/skills/frontend-accessibility.md

2Or fetch it directly
mkdir -p .gemini/skills && curl -fsSL https://webdeveloper.com/skills/frontend-accessibility/SKILL.md -o .gemini/skills/frontend-accessibility.md

Gemini CLI auto-loads project skills on the next run.

The full SKILL.md

399 lines · plain Markdown · MIT-licensed
SKILL.md
---
name: frontend-accessibility
description: >-
  Enforces WCAG 2.2 AA accessibility patterns when building front-end
  interfaces. Use when creating HTML markup, forms, modals, navigation,
  interactive components, or any user-facing UI, and when adding ARIA
  attributes, managing focus, or handling keyboard interaction.
---

# Front-end Accessibility (WCAG 2.2 AA)

Build interfaces that work for everyone: keyboard users, screen reader users,
users with low vision, motor impairments, and cognitive disabilities. WCAG 2.2
Level AA is the baseline — not a stretch goal.

This skill covers how to make components accessible. See the
`frontend-components` skill for which HTML elements and ARIA patterns to use.

## Keyboard Navigation

### Every interactive element must be keyboard-operable (2.1.1)

All functionality available by mouse must work by keyboard alone. Native
`<button>`, `<a>`, `<input>`, `<select>`, and `<textarea>` are keyboard-
accessible by default. Custom interactive elements must handle `keydown`.

If you find yourself adding `role="button"` and a `keydown` handler to a
`<div>`, stop — use a `<button>`. See the `frontend-components` skill.

### Focus order (2.4.3)

Focus must follow a logical reading order, which usually means source order.

| tabindex value | Behavior | Use |
|----------------|----------|-----|
| Not set / `0` | Follows source order | Default for all focusable elements |
| `-1` | Focusable via JS only, removed from tab order | Programmatic focus (roving tabindex, modal return focus) |
| `> 0` | **Never use** | Disrupts natural flow, creates maintenance nightmare |

### Skip navigation (2.4.1)

The first focusable element on every page must be a skip link:

```html
<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <header><!-- nav --></header>
  <main id="main-content"><!-- content --></main>
</body>
```

```css
.skip-link {
  position: absolute;
  inset-inline-start: -9999px;

  &:focus {
    position: fixed;
    inset-block-start: var(--space-sm);
    inset-inline-start: var(--space-sm);
    padding: var(--space-sm) var(--space-lg);
    background: var(--color-bg);
    color: var(--color-text-primary);
    z-index: var(--z-tooltip);
  }
}
```

### Focus trapping in modals (2.4.3)

When a modal is open, focus must stay inside it. Tab from the last focusable
element wraps to the first; Shift+Tab from the first wraps to the last.

Native `<dialog>` with `.showModal()` handles this automatically. For custom
implementations, track the first and last focusable elements and redirect
on boundary `keydown`. But prefer `<dialog>` — see `frontend-components`.

### Roving tabindex

For composite widgets (tabs, toolbars, menus, radio groups):

1. Active item: `tabindex="0"`
2. All other items: `tabindex="-1"`
3. Arrow keys move the active item
4. `Tab` exits the widget

This matches native OS behavior — the user Tabs into the widget, arrows
within it, and Tabs out.

## Focus Visibility (2.4.7, 2.4.11, 2.4.12)

### Always show focus indicators

```css
:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}
```

- Use `:focus-visible` not `:focus` — shows the ring for keyboard
  navigation, hides it for mouse clicks
- **Never** `outline: none` without a visible alternative
- Minimum outline width: 2px
- Outline must have 3:1 contrast against adjacent colors (2.4.11)

### Focus not obscured (2.4.11)

The focused element must be at least partially visible. It cannot be hidden
behind sticky headers, footers, or overlays:

```css
html {
  scroll-padding-block-start: 5rem;
}
```

Set `scroll-padding-block-start` to the height of any sticky header plus
a buffer. This ensures that when focus scrolls an element into view, it's
not hidden behind the header.

## Color and Contrast

### Minimum contrast ratios (1.4.3, 1.4.11)

| Element | Min ratio | WCAG criterion |
|---------|-----------|----------------|
| Normal text (< 18pt / < 14pt bold) | 4.5:1 | 1.4.3 |
| Large text (>= 18pt / >= 14pt bold) | 3:1 | 1.4.3 |
| UI components and graphical objects | 3:1 | 1.4.11 |
| Focus indicators | 3:1 against adjacent | 2.4.11 |

When creating new color tokens, verify contrast against the corresponding
on-surface pair. See the `frontend-design-system` skill for the
surface/on-surface pairing table.

### Don't rely on color alone (1.4.1)

Information conveyed by color must also be conveyed by another indicator:

- **Error states**: color + icon, border change, or text label
- **Required fields**: asterisk or "(required)" text, not just red border
- **Links in body text**: underline in addition to color difference
- **Charts**: patterns, labels, or shapes in addition to color

This is one of the most frequently failed WCAG criteria. Test by viewing
the page in grayscale — if any information disappears, it fails.

## Screen Reader Patterns

### Labels (4.1.2, 1.3.1)

Every interactive element needs an accessible name:

```html
<!-- Visible label (always preferred) -->
<label for="email">Email</label>
<input id="email" type="email">

<!-- aria-label for icon-only buttons -->
<button aria-label="Close dialog">
  <svg aria-hidden="true"><!-- X icon --></svg>
</button>

<!-- aria-labelledby for complex labels -->
<h2 id="billing-heading">Billing</h2>
<section aria-labelledby="billing-heading">
  <!-- billing content -->
</section>
```

Priority order:
1. Visible `<label>` — always best; users can see what the label says
2. `aria-labelledby` — references existing visible text
3. `aria-label` — last resort, invisible to sighted users

`aria-label` overrides visible text entirely. Don't use it to repeat what's
already visible — screen reader users hear it *instead of* the visible text.

### Descriptions (1.3.1)

Use `aria-describedby` for supplementary info (hints, errors, constraints):

```html
<label for="password">Password</label>
<input id="password" type="password"
       aria-describedby="password-rules password-error">
<p id="password-rules">At least 8 characters with a number.</p>
<p id="password-error" role="alert" hidden>Password is too short.</p>
```

### Hiding content

| Technique | Visible | In a11y tree | Use for |
|-----------|---------|-------------|---------|
| `display: none` | No | No | Fully hidden |
| `visibility: hidden` | No | No | Hidden, keeps space |
| `hidden` attribute | No | No | Semantically hidden |
| `.visually-hidden` | No | **Yes** | Screen reader only |
| `aria-hidden="true"` | **Yes** | No | Decorative (icons next to text) |

```css
.visually-hidden {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  block-size: 1px;
  inline-size: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
}
```

**Critical rule**: never set `aria-hidden="true"` on an element that is
focusable or contains focusable elements. This creates a focus trap that
screen readers can enter but can't announce.

## Forms (3.3.1, 3.3.2, 3.3.3)

### Validation and errors

```html
<div class="form-group">
  <label for="name">Full name</label>
  <input id="name" type="text" required
         aria-required="true"
         aria-invalid="true"
         aria-describedby="name-error">
  <p id="name-error" role="alert">Name is required.</p>
</div>
```

- Set `aria-invalid="true"` when validation fails
- Associate errors with `aria-describedby`
- Use `role="alert"` on error messages for immediate screen reader
  announcement
- Show errors inline next to the field, not just at the top
- **On submit failure, move focus to the first invalid field** — this is
  the single most impactful accessibility improvement for forms

### Required fields

```html
<label for="email">
  Email <span aria-hidden="true">*</span>
  <span class="visually-hidden">(required)</span>
</label>
<input id="email" type="email" required aria-required="true">
```

The asterisk is hidden from screen readers (it would be announced as
"star"). The "(required)" text is hidden from sighted users but announced
by screen readers.

### Grouping

Group related fields with `<fieldset>` and `<legend>`:

```html
<fieldset>
  <legend>Shipping address</legend>
  <label for="street">Street</label>
  <input id="street" type="text">
  <label for="city">City</label>
  <input id="city" type="text">
</fieldset>
```

Screen readers announce the legend before each field in the group, giving
context: "Shipping address, Street, edit text". Without the fieldset, the
user hears "Street, edit text" with no context.

## Images (1.1.1)

```html
<!-- Content image -->
<img src="/chart.png" alt="Bar chart showing 40% growth in Q1 2026">

<!-- Decorative image -->
<img src="/divider.svg" alt="">

<!-- Complex image with extended description -->
<figure>
  <img src="/diagram.png" alt="System architecture diagram"
       aria-describedby="diagram-desc">
  <figcaption id="diagram-desc">
    Three layers: React frontend, Node.js API, PostgreSQL database.
    The frontend calls the API via REST.
  </figcaption>
</figure>

<!-- Icon next to text -->
<button>
  <svg aria-hidden="true"><!-- icon --></svg>
  Delete item
</button>

<!-- Icon-only button -->
<button aria-label="Delete item">
  <svg aria-hidden="true"><!-- icon --></svg>
</button>
```

- Every `<img>` must have `alt`
- Decorative images: `alt=""`
- Icons next to text: `aria-hidden="true"` on the icon
- Icons without text: `aria-label` on the button/link

## Motion and Animation (2.3.1)

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
```

If the project uses motion tokens from `frontend-design-system`, the
token-based approach (setting durations to 0) is preferred — it's less
aggressive than the global `!important` override above. Use both only if
third-party components bypass the token system.

- No content should flash more than 3 times per second (2.3.1)
- Auto-playing video or animation must have pause/stop controls

## WCAG 2.2 Additions

### Dragging Movements (2.5.7)

Any draggable action must have a non-dragging alternative:

- Drag-to-reorder: provide up/down buttons
- Drag-to-resize: provide size input or toggle
- Drag-to-move: provide coordinate inputs or arrow keys

### Accessible Authentication (3.3.8)

Don't require cognitive function tests as the sole auth method:

- Passkeys, biometrics, or password managers (paste must work in password
  fields)
- Email/SMS codes as alternatives to CAPTCHA
- If using CAPTCHA, provide an audio or accessible alternative

### Redundant Entry (3.3.7)

Don't ask users to re-enter information already provided in the same
session. Auto-fill from previous steps, or allow copy-paste.

## Testing Workflow

For every component or page, test in this order (highest-impact first):

1. **Keyboard** — Tab through everything. Can you reach and operate every
   interactive element? Is focus visible? Does focus order make sense? Can
   you escape modals with Escape?

2. **Screen reader** — VoiceOver (macOS: `Cmd+F5`) or NVDA (Windows: free).
   Navigate by headings (`VO+Cmd+H`), landmarks (`VO+U`), and form controls.
   Are labels correct? Are live regions announced?

3. **Zoom** — 200% zoom. Does content reflow without horizontal scrolling?
   Is text readable? Are touch targets still reachable?

4. **Color** — Check contrast ratios with browser DevTools (Chrome: inspect
   element, look at contrast ratio in the color picker). View in grayscale
   to verify no information is color-only.

5. **Motion** — Enable "Reduce motion" in OS settings. Verify animations
   respect the preference.

A component is not done until it passes all 5 checks.

## Anti-Patterns

**Never do these:**

- Set `tabindex` > 0 — disrupts focus order for every user
- `outline: none` without an alternative — makes the page unusable for
  keyboard users
- `role="button"` on a `<div>` — use `<button>` (see `frontend-components`)
- `aria-label` that repeats visible text — causes double announcements in
  some screen readers
- `aria-hidden="true"` on focusable elements — creates invisible focus traps
- Color-only information (errors, status, links) — fails 1.4.1
- `placeholder` as `<label>` replacement — disappears when the user types,
  not announced reliably by all screen readers
- Nested interactive elements (`<a>` inside `<button>`) — ambiguous behavior
- Auto-playing audio/video without controls — fails 1.4.2
- `title` attribute as primary accessible name — inconsistent across
  assistive technologies (only works on hover, not on focus)
- Skip heading levels — breaks navigation for screen reader users who
  navigate by heading level
- Removing focus styles for aesthetics — if the design requires it, the
  design is wrong
Pair it

Stack it with the rest of the suite.

Front-end02 Components

Semantic HTML, ARIA widget patterns, native dialog and Popover API, heading hierarchy, progressive enhancement, BEM naming.

↗
Front-end04 Design Tokens

Two-tier token architecture, spacing and type scales, oklch color system, dark/light theming, motion tokens, and z-index scales.

↗
Review09 Testing Review

Behavior-first tests, error and edge-case coverage, accessibility contract tests, mock guidelines.

↗

Changelog

V2 April 9, 2026
Added WCAG success criterion references throughout, label priority order, aria-hidden focus trap warning, testing workflow with VoiceOver commands, screen reader fieldset behavior, and cross-references to Components and Design Tokens skills.
V1 April 3, 2026
Initial skill covering keyboard navigation, focus visibility, contrast ratios, screen reader patterns, forms, images, motion preferences, and WCAG 2.2 new criteria.

FAQ

What WCAG standard does this skill target?

This skill targets WCAG 2.2 Level AA compliance, which is the most widely adopted accessibility standard and the legal requirement in many jurisdictions. It covers perceivable, operable, understandable, and robust requirements including color contrast ratios (4.5:1 for normal text, 3:1 for large text), keyboard operability, and focus management.

How does this skill handle keyboard navigation?

The skill enforces that every interactive element is reachable and operable via keyboard alone. It requires visible focus indicators (minimum 2px solid outlines), logical tab order following DOM sequence, skip navigation links, roving tabindex for composite widgets, and focus trapping within modal dialogs. The tabindex attribute should never be set above 0.

Does this skill cover screen reader support?

Yes. The skill includes patterns for meaningful ARIA landmarks, live regions for dynamic content updates, proper labeling of form controls and interactive elements, status messages that announce to screen readers, and alt text guidelines for images. It also prevents common anti-patterns like using aria-hidden on focusable elements.

Which AI coding tools is this compatible with?

Cursor, Claude Code, Codex CLI, Windsurf, GitHub Copilot, and Gemini CLI. The skill is a single Markdown file and ships in the native format for each tool with one-click copy.

STATUS ● BUILDING THE FUTURE
MISSION MAKE AI SHIP BETTER CODE.
VERSION BETA 3.0

MAKE AI SHIP BETTER CODE.

@WEBDEVELOPERHQ ↗
TERMS / PRIVACY
FRIENDS
Authentic Jobs ↗
Web Reference ↗
Ready.dev ↗
Fullres ↗
© 2026 WEB DEVELOPER / ALL RIGHTS RESERVED