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