accessibility

Build WCAG 2.1 AA compliant websites with semantic HTML, ARIA, focus management, and screen reader support. Covers five core areas: semantic HTML element selection, ARIA patterns (labels, live regions, roles), keyboard navigation with focus traps, color contrast requirements (4.5:1 text, 3:1 UI), and accessible form design with validation Includes 12 documented accessibility issues with prevention strategies, from missing focus indicators to keyboard traps to insufficient contrast Provides code patterns for common components: dialogs with focus management, accessible tabs with arrow key navigation, skip links, and validated forms with error announcements Testing workflow covers keyboard-only verification, screen reader testing (NVDA/VoiceOver), and automated tools (axe DevTools, Lighthouse)

INSTALLATION
npx skills add https://github.com/jezweb/claude-skills --skill accessibility
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$2a

Submit

Next page

**Why this matters:**

- Semantic elements have built-in keyboard support

- Screen readers announce role automatically

- Browser provides default accessible behaviors

### 2. Focus Management

Make interactive elements keyboard-accessible:

/ ❌ WRONG - removes focus outline /

button:focus { outline: none; }

/ ✅ CORRECT - custom accessible outline /

button:focus-visible {

outline: 2px solid var(--primary);

outline-offset: 2px;

}


**CRITICAL:**

- Never remove focus outlines without replacement

- Use `:focus-visible` to show only on keyboard focus

- Ensure 3:1 contrast ratio for focus indicators

### 3. Text Alternatives

Every non-text element needs a text alternative:

<!-- ❌ WRONG - no alt text -->

<img src="logo.png">

<button><svg>...</svg></button>

<!-- ✅ CORRECT - proper alternatives -->

<img src="logo.png" alt="Company Name">

<button aria-label="Close dialog"><svg>...</svg></button>


## The 5-Step Accessibility Process

### Step 1: Choose Semantic HTML

**Decision tree for element selection:**

Need clickable element?

├─ Navigates to another page? → <a href="...">

├─ Submits form? → <button type="submit">

├─ Opens dialog? → <button aria-haspopup="dialog">

└─ Other action? → <button type="button">

Grouping content?

├─ Self-contained article? → <article>

├─ Thematic section? → <section>

├─ Navigation links? → <nav>

└─ Supplementary info? → <aside>

Form element?

├─ Text input? → <input type="text">

├─ Multiple choice? → <select> or <input type="radio">

├─ Toggle? → <input type="checkbox"> or <button aria-pressed>

└─ Long text? → <textarea>


**See `references/semantic-html.md` for complete guide.**

### Step 2: Add ARIA When Needed

**Golden rule: Use ARIA only when HTML can't express the pattern.**

<!-- ❌ WRONG - unnecessary ARIA -->

<button role="button">Click me</button> <!-- Button already has role -->

<!-- ✅ CORRECT - ARIA fills semantic gap -->

<div role="dialog" aria-labelledby="title" aria-modal="true">

<h2 id="title">Confirm action</h2>

<!-- No HTML dialog yet, so role needed -->

</div>

<!-- ✅ BETTER - Use native HTML when available -->

<dialog aria-labelledby="title">

<h2 id="title">Confirm action</h2>

</dialog>


**Common ARIA patterns:**

- `aria-label` - When visible label doesn't exist

- `aria-labelledby` - Reference existing text as label

- `aria-describedby` - Additional description

- `aria-live` - Announce dynamic updates

- `aria-expanded` - Collapsible/expandable state

**See `references/aria-patterns.md` for complete patterns.**

### Step 3: Implement Keyboard Navigation

**All interactive elements must be keyboard-accessible:**

// Tab order management

function Dialog({ onClose }) {

const dialogRef = useRef<HTMLDivElement>(null);

const previousFocus = useRef<HTMLElement | null>(null);

useEffect(() => {

// Save previous focus

previousFocus.current = document.activeElement as HTMLElement;

// Focus first element in dialog

const firstFocusable = dialogRef.current?.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');

(firstFocusable as HTMLElement)?.focus();

// Trap focus within dialog

const handleKeyDown = (e: KeyboardEvent) => {

if (e.key === 'Escape') onClose();

if (e.key === 'Tab') {

// Focus trap logic here

}

};

document.addEventListener('keydown', handleKeyDown);

return () => {

document.removeEventListener('keydown', handleKeyDown);

// Restore focus on close

previousFocus.current?.focus();

};

}, [onClose]);

return <div ref={dialogRef} role="dialog">...</div>;

}


**Essential keyboard patterns:**

- Tab/Shift+Tab: Navigate between focusable elements

- Enter/Space: Activate buttons/links

- Arrow keys: Navigate within components (tabs, menus)

- Escape: Close dialogs/menus

- Home/End: Jump to first/last item

**See `references/focus-management.md` for complete patterns.**

### Step 4: Ensure Color Contrast

**WCAG AA requirements:**

- Normal text (under 18pt): 4.5:1 contrast ratio

- Large text (18pt+ or 14pt+ bold): 3:1 contrast ratio

- UI components (buttons, borders): 3:1 contrast ratio

/ ❌ WRONG - insufficient contrast /

:root {

--background: #ffffff;

--text: #999999; / 2.8:1 - fails WCAG AA /

}

/ ✅ CORRECT - sufficient contrast /

:root {

--background: #ffffff;

--text: #595959; / 4.6:1 - passes WCAG AA /

}


**Testing tools:**

- Browser DevTools (Chrome/Firefox have built-in checkers)

- Contrast checker extensions

- axe DevTools extension

**See `references/color-contrast.md` for complete guide.**

### Step 5: Make Forms Accessible

**Every form input needs a visible label:**

<!-- ❌ WRONG - placeholder is not a label -->

<input type="email" placeholder="Email address">

<!-- ✅ CORRECT - proper label -->

<label for="email">Email address</label>

<input type="email" id="email" name="email" required aria-required="true">


**Error handling:**

<label for="email">Email address</label>

<input

type="email"

id="email"

name="email"

aria-invalid="true"

aria-describedby="email-error"

>

<span id="email-error" role="alert">

Please enter a valid email address

</span>


**Live regions for dynamic errors:**

<div role="alert" aria-live="assertive" aria-atomic="true">

Form submission failed. Please fix the errors above.

</div>


**See `references/forms-validation.md` for complete patterns.**

## Critical Rules

### Always Do

✅ Use semantic HTML elements first (button, a, nav, article, etc.)
✅ Provide text alternatives for all non-text content
✅ Ensure 4.5:1 contrast for normal text, 3:1 for large text/UI
✅ Make all functionality keyboard accessible
✅ Test with keyboard only (unplug mouse)
✅ Test with screen reader (NVDA on Windows, VoiceOver on Mac)
✅ Use proper heading hierarchy (h1 → h2 → h3, no skipping)
✅ Label all form inputs with visible labels
✅ Provide focus indicators (never just `outline: none`)
✅ Use `aria-live` for dynamic content updates

### Never Do

❌ Use `div` with `onClick` instead of `button`
❌ Remove focus outlines without replacement
❌ Use color alone to convey information
❌ Use placeholders as labels
❌ Skip heading levels (h1 → h3)
❌ Use `tabindex` > 0 (messes with natural order)
❌ Add ARIA when semantic HTML exists
❌ Forget to restore focus after closing dialogs
❌ Use `role="presentation"` on focusable elements
❌ Create keyboard traps (no way to escape)

## Known Issues Prevention

This skill prevents **12** documented accessibility issues:

### Issue #1: Missing Focus Indicators

**Error**: Interactive elements have no visible focus indicator
**Source**: WCAG 2.4.7 (Focus Visible)
**Why It Happens**: CSS reset removes default outline
**Prevention**: Always provide custom focus-visible styles

### Issue #2: Insufficient Color Contrast

**Error**: Text has less than 4.5:1 contrast ratio
**Source**: WCAG 1.4.3 (Contrast Minimum)
**Why It Happens**: Using light gray text on white background
**Prevention**: Test all text colors with contrast checker

### Issue #3: Missing Alt Text

**Error**: Images missing alt attributes
**Source**: WCAG 1.1.1 (Non-text Content)
**Why It Happens**: Forgot to add or thought it was optional
**Prevention**: Add alt="" for decorative, descriptive alt for meaningful images

### Issue #4: Keyboard Navigation Broken

**Error**: Interactive elements not reachable by keyboard
**Source**: WCAG 2.1.1 (Keyboard)
**Why It Happens**: Using div onClick instead of button
**Prevention**: Use semantic interactive elements (button, a)

### Issue #5: Form Inputs Without Labels

**Error**: Input fields missing associated labels
**Source**: WCAG 3.3.2 (Labels or Instructions)
**Why It Happens**: Using placeholder as label
**Prevention**: Always use `<label>` element with for/id association

### Issue #6: Skipped Heading Levels

**Error**: Heading hierarchy jumps from h1 to h3
**Source**: WCAG 1.3.1 (Info and Relationships)
**Why It Happens**: Using headings for visual styling instead of semantics
**Prevention**: Use headings in order, style with CSS

### Issue #7: No Focus Trap in Dialogs

**Error**: Tab key exits dialog to background content
**Source**: WCAG 2.4.3 (Focus Order)
**Why It Happens**: No focus trap implementation
**Prevention**: Implement focus trap for modal dialogs

### Issue #8: Missing aria-live for Dynamic Content

**Error**: Screen reader doesn't announce updates
**Source**: WCAG 4.1.3 (Status Messages)
**Why It Happens**: Dynamic content added without announcement
**Prevention**: Use aria-live="polite" or "assertive"

### Issue #9: Color-Only Information

**Error**: Using only color to convey status
**Source**: WCAG 1.4.1 (Use of Color)
**Why It Happens**: Red text for errors without icon/text
**Prevention**: Add icon + text label, not just color

### Issue #10: Non-descriptive Link Text

**Error**: Links with "click here" or "read more"
**Source**: WCAG 2.4.4 (Link Purpose)
**Why It Happens**: Generic link text without context
**Prevention**: Use descriptive link text or aria-label

### Issue #11: Auto-playing Media

**Error**: Video/audio auto-plays without user control
**Source**: WCAG 1.4.2 (Audio Control)
**Why It Happens**: Autoplay attribute without controls
**Prevention**: Require user interaction to start media

### Issue #12: Inaccessible Custom Controls

**Error**: Custom select/checkbox without keyboard support
**Source**: WCAG 4.1.2 (Name, Role, Value)
**Why It Happens**: Building from divs without ARIA
**Prevention**: Use native elements or implement full ARIA pattern

## WCAG 2.1 AA Quick Checklist

### Perceivable

-  All images have alt text (or alt="" if decorative)

-  Text contrast ≥ 4.5:1 (normal), ≥ 3:1 (large)

-  Color not used alone to convey information

-  Text can be resized to 200% without loss of content

-  No auto-playing audio >3 seconds

### Operable

-  All functionality keyboard accessible

-  No keyboard traps

-  Visible focus indicators

-  Users can pause/stop/hide moving content

-  Page titles describe purpose

-  Focus order is logical

-  Link purpose clear from text or context

-  Multiple ways to find pages (menu, search, sitemap)

-  Headings and labels describe purpose

### Understandable

-  Page language specified (`<html lang="en">`)

-  Language changes marked (`<span lang="es">`)

-  No unexpected context changes on focus/input

-  Consistent navigation across site

-  Form labels/instructions provided

-  Input errors identified and described

-  Error prevention for legal/financial/data changes

### Robust

-  Valid HTML (no parsing errors)

-  Name, role, value available for all UI components

-  Status messages identified (aria-live)

## Testing Workflow

### 1. Keyboard-Only Testing (5 minutes)
  1. Unplug mouse or hide cursor
  1. Tab through entire page

- Can you reach all interactive elements?

- Can you activate all buttons/links?

- Is focus order logical?

  1. Use Enter/Space to activate
  1. Use Escape to close dialogs
  1. Use arrow keys in menus/tabs
  2. 
    ### 2. Screen Reader Testing (10 minutes)
    
    **NVDA (Windows - Free)**:
    
    - Download: [https://www.nvaccess.org/download/](https://www.nvaccess.org/download/)
    
    - Start: Ctrl+Alt+N
    
    - Navigate: Arrow keys or Tab
    
    - Read: NVDA+Down arrow
    
    - Stop: NVDA+Q
    
    **VoiceOver (Mac - Built-in)**:
    
    - Start: Cmd+F5
    
    - Navigate: VO+Right/Left arrow (VO = Ctrl+Option)
    
    - Read: VO+A (read all)
    
    - Stop: Cmd+F5
    
    **What to test:**
    
    - Are all interactive elements announced?
    
    - Are images described properly?
    
    - Are form labels read with inputs?
    
    - Are dynamic updates announced?
    
    - Is heading structure clear?
    
    ### 3. Automated Testing
    
    **axe DevTools** (Browser extension - highly recommended):
    
    - Install: Chrome/Firefox extension
    
    - Run: F12 → axe DevTools tab → Scan
    
    - Fix: Review violations, follow remediation
    
    - Retest: Scan again after fixes
    
    **Lighthouse** (Built into Chrome):
    
    - Open DevTools (F12)
    
    - Lighthouse tab
    
    - Select "Accessibility" category
    
    - Generate report
    
    - Score 90+ is good, 100 is ideal
    
    ## Common Patterns
    
    ### Pattern 1: Accessible Dialog/Modal
    

interface DialogProps {

isOpen: boolean;

onClose: () => void;

title: string;

children: React.ReactNode;

}

function Dialog({ isOpen, onClose, title, children }: DialogProps) {

const dialogRef = useRef<HTMLDivElement>(null);

useEffect(() => {

if (!isOpen) return;

const previousFocus = document.activeElement as HTMLElement;

// Focus first focusable element

const firstFocusable = dialogRef.current?.querySelector(

'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'

) as HTMLElement;

firstFocusable?.focus();

// Focus trap

const handleKeyDown = (e: KeyboardEvent) => {

if (e.key === 'Escape') {

onClose();

}

if (e.key === 'Tab') {

const focusableElements = dialogRef.current?.querySelectorAll(

'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'

);

if (!focusableElements?.length) return;

const first = focusableElements[0] as HTMLElement;

const last = focusableElements[focusableElements.length - 1] as HTMLElement;

if (e.shiftKey &#x26;&#x26; document.activeElement === first) {

e.preventDefault();

last.focus();

} else if (!e.shiftKey &#x26;&#x26; document.activeElement === last) {

e.preventDefault();

first.focus();

}

}

};

document.addEventListener('keydown', handleKeyDown);

return () => {

document.removeEventListener('keydown', handleKeyDown);

previousFocus?.focus();

};

}, [isOpen, onClose]);

if (!isOpen) return null;

return (

<>

{/ Backdrop /}

<div

className="dialog-backdrop"

onClick={onClose}

aria-hidden="true"

/>

{/ Dialog /}

<div

ref={dialogRef}

role="dialog"

aria-modal="true"

aria-labelledby="dialog-title"

className="dialog"

>

<h2 id="dialog-title">{title}</h2>

<div className="dialog-content">{children}</div>

<button onClick={onClose} aria-label="Close dialog">×</button>

</div>

</>

);

}


**When to use**: Any modal dialog or overlay that blocks interaction with background content.

### Pattern 2: Accessible Tabs

function Tabs({ tabs }: { tabs: Array<{ label: string; content: React.ReactNode }> }) {

const [activeIndex, setActiveIndex] = useState(0);

const handleKeyDown = (e: React.KeyboardEvent, index: number) => {

if (e.key === 'ArrowLeft') {

e.preventDefault();

const newIndex = index === 0 ? tabs.length - 1 : index - 1;

setActiveIndex(newIndex);

} else if (e.key === 'ArrowRight') {

e.preventDefault();

const newIndex = index === tabs.length - 1 ? 0 : index + 1;

setActiveIndex(newIndex);

} else if (e.key === 'Home') {

e.preventDefault();

setActiveIndex(0);

} else if (e.key === 'End') {

e.preventDefault();

setActiveIndex(tabs.length - 1);

}

};

return (

<div>

<div role="tablist" aria-label="Content tabs">

{tabs.map((tab, index) => (

<button

key={index}

role="tab"

aria-selected={activeIndex === index}

aria-controls={panel-${index}}

id={tab-${index}}

tabIndex={activeIndex === index ? 0 : -1}

onClick={() => setActiveIndex(index)}

onKeyDown={(e) => handleKeyDown(e, index)}

>

{tab.label}

</button>

))}

</div>

{tabs.map((tab, index) => (

<div

key={index}

role="tabpanel"

id={panel-${index}}

aria-labelledby={tab-${index}}

hidden={activeIndex !== index}

tabIndex={0}

>

{tab.content}

</div>

))}

</div>

);

}


**When to use**: Tabbed interface with multiple panels.

### Pattern 3: Skip Links

<!-- Place at very top of body -->

<a href="#main-content" class="skip-link">

Skip to main content

</a>

<style>

.skip-link {

position: absolute;

top: -40px;

left: 0;

background: var(--primary);

color: white;

padding: 8px 16px;

z-index: 9999;

}

.skip-link:focus {

top: 0;

}

</style>

<!-- Then in your layout -->

<main id="main-content" tabindex="-1">

<!-- Page content -->

</main>


**When to use**: All multi-page websites with navigation/header before main content.

### Pattern 4: Accessible Form with Validation

function ContactForm() {

const [errors, setErrors] = useState<Record<string, string>>({});

const [touched, setTouched] = useState<Record<string, boolean>>({});

const validateEmail = (email: string) => {

if (!email) return 'Email is required';

if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Email is invalid';

return '';

};

const handleBlur = (field: string, value: string) => {

setTouched(prev => ({ ...prev, [field]: true }));

const error = validateEmail(value);

setErrors(prev => ({ ...prev, [field]: error }));

};

return (

<form>

<div>

<label htmlFor="email">Email address *</label>

<input

type="email"

id="email"

name="email"

required

aria-required="true"

aria-invalid={touched.email &#x26;&#x26; !!errors.email}

aria-describedby={errors.email ? 'email-error' : undefined}

onBlur={(e) => handleBlur('email', e.target.value)}

/>

{touched.email &#x26;&#x26; errors.email &#x26;&#x26; (

<span id="email-error" role="alert" className="error">

{errors.email}

</span>

)}

</div>

<button type="submit">Submit</button>

{/ Global form error /}

<div role="alert" aria-live="assertive" aria-atomic="true">

{/ Dynamic error message appears here /}

</div>

</form>

);

}


**When to use**: All forms with validation.

## Using Bundled Resources

### References (references/)

Detailed documentation for deep dives:

- **wcag-checklist.md** - Complete WCAG 2.1 Level A &#x26; AA requirements with examples

- **semantic-html.md** - Element selection guide, when to use which tag

- **aria-patterns.md** - ARIA roles, states, properties, and when to use them

- **focus-management.md** - Focus order, focus traps, focus restoration patterns

- **color-contrast.md** - Contrast requirements, testing tools, color palette tips

- **forms-validation.md** - Accessible form patterns, error handling, announcements

**When Claude should load these**:

- User asks for complete WCAG checklist

- Deep dive into specific pattern (tabs, accordions, etc.)

- Color contrast issues or palette design

- Complex form validation scenarios

### Agents (agents/)

- **a11y-auditor.md** - Automated accessibility auditor that checks pages for violations

**When to use**: Request accessibility audit of existing page/component.

## Advanced Topics

### ARIA Live Regions

Three politeness levels:

<!-- Polite: Wait for screen reader to finish current announcement -->

<div aria-live="polite">New messages: 3</div>

<!-- Assertive: Interrupt immediately -->

<div aria-live="assertive" role="alert">

Error: Form submission failed

</div>

<!-- Off: Don't announce (default) -->

<div aria-live="off">Loading...</div>


**Best practices:**

- Use `polite` for non-critical updates (notifications, counters)

- Use `assertive` for errors and critical alerts

- Use `aria-atomic="true"` to read entire region on change

- Keep messages concise and meaningful

### Focus Management in SPAs

React Router doesn't reset focus on navigation - you need to handle it:

function App() {

const location = useLocation();

const mainRef = useRef<HTMLElement>(null);

useEffect(() => {

// Focus main content on route change

mainRef.current?.focus();

// Announce page title to screen readers

const title = document.title;

const announcement = document.createElement('div');

announcement.setAttribute('role', 'status');

announcement.setAttribute('aria-live', 'polite');

announcement.textContent = Navigated to ${title};

document.body.appendChild(announcement);

setTimeout(() => announcement.remove(), 1000);

}, [location.pathname]);

return <main ref={mainRef} tabIndex={-1} id="main-content">...</main>;

}


### Accessible Data Tables

<table>

<caption>Monthly sales by region</caption>

<thead>

<tr>

<th scope="col">Region</th>

<th scope="col">Q1</th>

<th scope="col">Q2</th>

</tr>

</thead>

<tbody>

<tr>

<th scope="row">North</th>

<td>$10,000</td>

<td>$12,000</td>

</tr>

</tbody>

</table>


**Key attributes:**

- `<caption>` - Describes table purpose

- `scope="col"` - Identifies column headers

- `scope="row"` - Identifies row headers

- Associates data cells with headers for screen readers

## Official Documentation

- **WCAG 2.1**: [https://www.w3.org/WAI/WCAG21/quickref/](https://www.w3.org/WAI/WCAG21/quickref/)

- **MDN Accessibility**: [https://developer.mozilla.org/en-US/docs/Web/Accessibility](https://developer.mozilla.org/en-US/docs/Web/Accessibility)

- **ARIA Authoring Practices**: [https://www.w3.org/WAI/ARIA/apg/](https://www.w3.org/WAI/ARIA/apg/)

- **WebAIM**: [https://webaim.org/articles/](https://webaim.org/articles/)

- **axe DevTools**: [https://www.deque.com/axe/devtools/](https://www.deque.com/axe/devtools/)

## Troubleshooting

### Problem: Focus indicators not visible

**Symptoms**: Can tab through page but don't see where focus is
**Cause**: CSS removed outlines or insufficient contrast
**Solution**:

*:focus-visible {

outline: 2px solid var(--primary);

outline-offset: 2px;

}

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card