Use when building or reviewing UI components for keyboard and screen reader compatibility, adding ARIA to custom widgets, auditing a page for WCAG AA conformance, or preparing for a formal accessibility review.
189 stars
0 votes
0 copies
0 views
Added September 25, 2026
designtypescriptgobashexpresstestinggit
Works with
cli
Security analysis
A96/100
mediumInstalls packages at runtime which could introduce malicious dependencies
Installs into .claude/skills of the current project.
Are you the author of Accessibility?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/kid-sid-accessibility)
---
name: accessibility
description: Use when building or reviewing UI components for keyboard and screen reader compatibility, adding ARIA to custom widgets, auditing a page for WCAG AA conformance, or preparing for a formal accessibility review.
---
# Accessibility
Build UIs that work for everyone — keyboard users, screen reader users, users with motor impairments, low-vision users, and users in constrained environments.
## When to Activate
- Building or reviewing UI components (forms, modals, menus, tables)
- Implementing keyboard navigation or focus management
- Auditing a page or component for WCAG conformance
- Adding ARIA attributes to custom interactive widgets
- Reviewing color palette or typography choices for contrast
- Writing E2E tests that must be accessible-first
- Preparing for an accessibility audit or compliance review
## WCAG Conformance Levels
| Level | What it means | Typical target |
|---|---|---|
| A | Bare minimum — removes critical blockers | Never ship below this |
| AA | Standard legal/compliance baseline (most regulations) | Default target |
| AAA | Enhanced — not always achievable for all content | Aim where practical |
Most legal requirements (ADA, WCAG 2.1 AA, EN 301 549) map to **AA**.
## Semantic HTML First
Use the right element before reaching for ARIA.
```html
<!-- BAD: div soup -->
<div class="btn" onclick="submit()">Submit</div>
<div class="nav"><div class="nav-item">Home</div></div>
<div class="heading">Page Title</div>
<!-- GOOD: native semantics -->
<button type="submit">Submit</button>
<nav><a href="/">Home</a></nav>
<h1>Page Title</h1>
```
### Landmark Regions
```html
<header> <!-- banner landmark -->
<nav> <!-- navigation landmark -->
<main> <!-- main landmark (one per page) -->
<aside> <!-- complementary landmark -->
<footer> <!-- contentinfo landmark -->
<section aria-label="Featured articles"> <!-- region landmark (needs label) -->
```
### Heading Hierarchy
```html
<!-- BAD: skipped levels, visual-only sizing -->
<h1>Site Name</h1>
<h3>Section Title</h3> <!-- skipped h2 -->
<span class="h2-style">Visual heading, not semantic</span>
<!-- GOOD: sequential, logical outline -->
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
```
## ARIA
Use ARIA only when native HTML cannot express the semantics.
### Rule of thumb
```
1. Use native HTML element → <button>, <input>, <select>
2. Use native attribute → disabled, required, checked
3. Use ARIA role + state + property → last resort for custom widgets
```
### Common Roles
```html
<!-- Roles that convey widget type -->
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<div role="alertdialog" aria-labelledby="alert-title" aria-describedby="alert-desc">
<div role="tablist"><div role="tab" aria-selected="true"><div role="tabpanel">
<ul role="listbox"><li role="option" aria-selected="false">
<!-- Roles for landmarks (prefer native elements) -->
<div role="banner"> <!-- prefer <header> -->
<div role="main"> <!-- prefer <main> -->
<div role="navigation"> <!-- prefer <nav> -->
```
### States and Properties
```html
<!-- Expandable widgets -->
<button aria-expanded="false" aria-controls="menu-id">Menu</button>
<ul id="menu-id" hidden>...</ul>
<!-- Required and invalid fields -->
<input aria-required="true" aria-invalid="true" aria-describedby="error-id">
<span id="error-id" role="alert">Email is required</span>
<!-- Live regions -->
<div aria-live="polite"> <!-- non-urgent updates (status messages) -->
<div aria-live="assertive" role="alert"> <!-- urgent (errors) -->
<!-- Labelling -->
<button aria-label="Close dialog">✕</button>
<input aria-labelledby="label-id hint-id"> <!-- multiple labels -->
<table aria-describedby="table-desc">
```
### ARIA Anti-Patterns
```html
<!-- BAD: redundant roles -->
<button role="button"> <!-- button already has this role -->
<ul role="list"> <!-- ul already has this role -->
<a href="/" role="link"> <!-- redundant -->
<!-- BAD: presentational role on interactive element -->
<button role="presentation">Submit</button>
<!-- BAD: ARIA label overrides visible text -->
<button aria-label="Click here to proceed">Submit order</button>
<!-- Screen reader says "Click here to proceed" — inconsistent with visual -->
```
## Keyboard Navigation
### Focus Order
```html
<!-- Logical DOM order = logical tab order -->
<!-- Avoid tabindex > 0 — it creates a separate, confusing tab sequence -->
<!-- BAD -->
<button tabindex="3">First visually</button>
<button tabindex="1">Second visually</button>
<!-- GOOD: use DOM order, override with tabindex="0" only to add focusability -->
<div tabindex="0" role="button">Custom focusable</div>
<!-- Remove from tab order when visually hidden -->
<div hidden> <!-- tabindex automatically removed -->
<div aria-hidden="true" tabindex="-1"> <!-- hidden from AT, not in tab order -->
```
### 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<HTMLElement>(
'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<HTMLElement>(
'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
<!-- BAD: color-only status -->
<span class="status-dot green"></span>
<!-- GOOD: color + text/icon -->
<span class="status-dot green" aria-label="Active"></span>Active
<!-- BAD: error indicated only by red border -->
<input class="error-border">
<!-- GOOD: error icon + text + aria-invalid -->
<input aria-invalid="true" aria-describedby="err">
<span id="err">⚠ Email is required</span>
```
### 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
<!-- BAD: placeholder as label -->
<input type="email" placeholder="Enter email">
<!-- GOOD: explicit label, associated by for/id -->
<label for="email">Email address</label>
<input id="email" type="email" autocomplete="email"
aria-required="true" aria-describedby="email-hint">
<span id="email-hint">We'll only use this to send your receipt.</span>
<!-- Group related fields -->
<fieldset>
<legend>Shipping address</legend>
<label for="street">Street</label>
<input id="street" type="text" autocomplete="street-address">
</fieldset>
<!-- Error association -->
<label for="phone">Phone number</label>
<input id="phone" type="tel" aria-invalid="true" aria-describedby="phone-error">
<span id="phone-error" role="alert">Enter a valid 10-digit phone number</span>
```
## Images and Media
```html
<!-- Informative image: describe the content -->
<img src="chart.png" alt="Bar chart showing Q4 revenue up 23% vs Q3">
<!-- Decorative image: empty alt suppresses announcement -->
<img src="divider.png" alt="">
<!-- Icon button: label the action, not the icon -->
<button aria-label="Delete item"><svg aria-hidden="true">...</svg></button>
<!-- Complex image: long description -->
<figure>
<img src="org-chart.png" alt="Organisation chart — see table below for text version">
<figcaption><!-- or link to detailed description --></figcaption>
</figure>
<!-- Video: captions + audio description -->
<video>
<track kind="captions" src="captions-en.vtt" srclang="en" label="English" default>
<track kind="descriptions" src="descriptions.vtt" srclang="en" label="Audio descriptions">
</video>
```
## 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 `<div>` or `<span>`** — custom button roles require manually implementing keyboard behavior; use `<button>` 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 `<label>` 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 `<img>` has `alt` — descriptive for content, empty for decorative
- [ ] All form inputs have associated `<label>` — 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 `<main>`, logical heading hierarchy starting at `h1`
- [ ] Automated scan (axe/pa11y) passes with zero violations
> See also: `solution-testing`, `coding-standards`