```
### Keyboard Patterns by Widget
| Widget | Keys required |
|---|---|
| Button | `Enter`, `Space` |
| Link | `Enter` |
| Checkbox | `Space` to toggle |
| Radio group | `Arrow` keys within group, `Tab` to leave |
| Select / Listbox | `Arrow` keys, `Enter`, `Escape` |
| Modal dialog | `Tab`/`Shift+Tab` trapped inside, `Escape` closes |
| Menu | `Arrow` keys, `Escape`, `Enter`/`Space` to select |
| Tabs | `Arrow` keys between tabs, `Tab` into panel |
| Slider | `Arrow` keys, `Home`, `End` |
### Focus Management
```typescript
// Move focus into dialog when it opens
function openModal(modalEl: HTMLElement) {
const firstFocusable = modalEl.querySelector
(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
// Trap focus inside modal
function trapFocus(modalEl: HTMLElement, event: KeyboardEvent) {
if (event.key !== "Tab") return;
const focusable = modalEl.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
last.focus(); event.preventDefault();
} else if (!event.shiftKey && document.activeElement === last) {
first.focus(); event.preventDefault();
}
}
// Return focus to trigger when dialog closes
function closeModal(triggerEl: HTMLElement) {
triggerEl.focus();
}
```
## Color and Visual Design
### Contrast Ratios (WCAG AA)
| Text | Minimum ratio | Enhanced (AAA) |
|---|---|---|
| Normal text (< 18pt / 14pt bold) | 4.5:1 | 7:1 |
| Large text (≥ 18pt / 14pt bold) | 3:1 | 4.5:1 |
| UI components, icons, graphical elements | 3:1 | — |
| Decorative content | No requirement | — |
```css
/* BAD: low contrast */
color: #999; /* #999 on white = 2.85:1 */
color: #767676; /* borderline 4.54:1 — just passes but risky */
/* GOOD: reliable contrast */
color: #595959; /* 7.0:1 on white */
color: #0066cc; /* 5.74:1 on white — accessible blue */
```
### Don't Rely on Color Alone
```html
Active
⚠ Email is required
```
### Motion and Animation
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
## Forms
```html
Email address
We'll only use this to send your receipt.
Shipping address
Street
Phone number
Enter a valid 10-digit phone number
```
## Images and Media
```html
...
```
## Testing
### Manual checklist (do for every component)
1. Tab through the page — every interactive element reachable?
2. Nothing requires a mouse — all actions completable by keyboard?
3. Focus indicator always visible?
4. Test with screen reader (NVDA/Firefox on Windows, VoiceOver/Safari on Mac)
### Automated tools
```bash
# Axe CLI
npx axe http://localhost:3000 --reporter cli
# Pa11y
npx pa11y http://localhost:3000
# Playwright + axe-core
npm install @axe-core/playwright
```
```typescript
// Playwright accessibility scan
import { checkA11y, injectAxe } from "axe-playwright";
test("home page has no accessibility violations", async ({ page }) => {
await page.goto("/");
await injectAxe(page);
await checkA11y(page, null, {
detailedReport: true,
detailedReportOptions: { html: true },
});
});
```
### Contrast check
```bash
# Check contrast via CLI
npx contrast-ratio "#595959" "#ffffff"
# Or use browser DevTools: Accessibility panel → Contrast ratio
```
## Red Flags
- **`aria-label` on every element** — ARIA overrides native semantics; use semantic HTML first and add ARIA only when native elements can't express the required role or state
- **`role="button"` on a `` or `
`** — custom button roles require manually implementing keyboard behavior; use `` and get focus, Enter, and Space for free
- **`alt=""` on informational images** — empty alt hides the image from screen readers entirely; empty alt is only correct for purely decorative images that convey no content
- **`placeholder` as the only label** — placeholder text disappears on focus and has insufficient contrast ratio; every input must have an associated visible `` element
- **`outline: none` without a visible replacement** — removing the default focus ring makes keyboard navigation invisible; always provide a visible focus indicator that meets 3:1 contrast
- **Automated scan as the complete accessibility test** — axe/pa11y catches ~30% of WCAG issues; focus order, reading order, and screen reader announcements require manual verification
- **Modal that doesn't trap focus** — focus that escapes an open modal to background content disorients screen reader users; implement a focus trap and return focus to the trigger element on close
## Checklist
- [ ] All interactive elements reachable and operable by keyboard alone
- [ ] Focus order follows logical reading order — no `tabindex > 0`
- [ ] Focus indicator visible at all times (no `outline: none` without replacement)
- [ ] Custom widgets implement correct ARIA role, state, and keyboard pattern
- [ ] Every ` ` has `alt` — descriptive for content, empty for decorative
- [ ] All form inputs have associated `` — not placeholder only
- [ ] Error messages programmatically associated with their input (`aria-describedby`)
- [ ] Color contrast ≥ 4.5:1 for normal text, ≥ 3:1 for large text and UI components
- [ ] No information conveyed by color alone
- [ ] `prefers-reduced-motion` respected for animations
- [ ] Modal dialogs trap focus and return it to trigger on close
- [ ] Page has a single ``, logical heading hierarchy starting at `h1`
- [ ] Automated scan (axe/pa11y) passes with zero violations
> See also: `solution-testing`, `coding-standards`