Accessibility
Skill fernando-bertholdo/4-successful-AI-life/plugins/ui-excellence/skills/foundations/accessibility
Curated Claude Code plugin marketplace — opinionated, production-ready skills for AI-assisted work, focused on craft, rigor, and practical excellence.
npx -y skills add fernando-bertholdo/4-successful-AI-life --skill accessibilityAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when building or reviewing UI components to ensure WCAG 2.1 compliance, keyboard navigation, semantic HTML, ARIA attributes, color contrast, and assistive technology support.
SKILL.md
26.2 KB, as published. Nobody here has run it
UI Accessibility (WCAG 2.1)
Overview
This skill guides implementation and review of accessible UI components following WCAG 2.1 Level AA standards. Accessibility ensures your interface is usable by everyone—keyboard users, screen reader users, users with low vision, and users with cognitive disabilities.
Core principle: Semantic HTML first, ARIA last. Keyboard navigation and screen reader support are non-negotiable.
When to Use / When NOT to Use
✅ Use This Skill When
- Building new UI components (buttons, modals, dropdowns, forms, tabs)
- Adding keyboard navigation or focus management
- Reviewing code for accessibility violations
- Testing with screen readers or keyboard-only
- Ensuring color contrast and visual focus indicators
- Adding ARIA attributes to dynamic content
❌ Do NOT Use When
- Component is purely decorative (no interaction needed)
- Audience explicitly doesn't include users with accessibility needs (internally impossible—always assume diverse users)
- You're skipping automated testing due to time constraints (violates DoD)
Core Concepts
1. Semantic HTML Structure
Foundation: Use meaningful HTML elements to convey structure and purpose.
<!-- ✅ CORRECT: Semantic structure -->
<header role="banner">
<nav aria-label="Main navigation">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main id="main-content">
<article>
<h1>Page Title</h1>
<section>
<h2>Section Heading</h2>
<p>Content...</p>
</section>
</article>
</main>
<footer>
<p>© 2026 Company</p>
</footer>
<!-- ✅ CORRECT: Proper heading hierarchy -->
<h1>Main Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
<!-- ❌ WRONG: Non-semantic div soup -->
<div class="header">
<div class="nav">
<div>Home</div>
<div>About</div>
</div>
</div>
<div class="main">
<div class="title">Page Title</div>
</div>
<!-- ❌ WRONG: Skipping heading hierarchy -->
<h1>Main Title</h1>
<h3>Jumps to h3 (breaks hierarchy)</h3>
Label Association Rules:
<!-- ✅ CORRECT: Explicit label-input association -->
<label for="email-input">Email Address</label>
<input id="email-input" type="email" required />
<!-- ✅ CORRECT: Implicit label wrapping -->
<label>
Email Address
<input type="email" required />
</label>
<!-- ❌ WRONG: Placeholder alone (insufficient) -->
<input type="email" placeholder="Enter email" />
<!-- Screen readers read placeholder only on focus; users don't see it in context -->
<!-- ❌ WRONG: No association -->
<label>Email Address</label>
<input type="email" />
2. Keyboard Navigation
Principle: All interactive features usable via keyboard—Tab, Shift+Tab, Enter, Space, Arrow keys, Escape.
Key Bindings:
- Tab / Shift+Tab: Move focus forward/backward
- Enter / Space: Activate buttons, toggle checkboxes
- Arrow keys: Navigate lists, menus, sliders, tabs (up/down for vertical; left/right for horizontal)
- Home / End: Jump to first/last item in list
- Escape: Close modal, dropdown, or menu
Focus Management:
<!-- ✅ CORRECT: Explicit focus for interactive elements -->
<button tabindex="0">Click me</button>
<!-- ✅ CORRECT: Programmatic focus (not in tab order) -->
<div tabindex="-1" id="skip-target">Skip to main content</div>
<!-- ❌ WRONG: Positive tabindex > 0 (breaks tab order) -->
<button tabindex="1">First</button>
<button tabindex="2">Second</button>
<!-- Order now: tabindex 1, 2, then remaining DOM order (confusing!) -->
<!-- ❌ WRONG: Removed focus outline -->
button:focus {
outline: none; /* NEVER DO THIS */
}
Skip Links (Focus Management):
<!-- Place at TOP of page, hidden by default -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<style>
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
}
.skip-link:focus {
left: 0;
top: 0;
background: #000;
color: #fff;
padding: 8px 12px;
}
</style>
<main id="main-content" tabindex="-1">
<!-- Content -->
</main>
3. ARIA Attributes
Golden Rule: Semantic HTML first. Use ARIA only when native HTML doesn't exist or to enhance semantics.
Common ARIA Attributes
| Attribute | Purpose | Example |
|---|---|---|
aria-label | Names element (for icons, buttons without text) | <button aria-label="Close menu">×</button> |
aria-labelledby | Links to another element as label | <h2 id="dialog-title">Confirm Delete</h2><div aria-labelledby="dialog-title">... |
aria-describedby | Adds descriptive text (error, hint) | <input aria-describedby="pwd-hint" /><p id="pwd-hint">Min 8 chars |
aria-live | Announces dynamic content ("polite" or "assertive") | <div aria-live="polite">Item added to cart</div> |
aria-hidden | Hides from screen readers (decorative only) | <span aria-hidden="true">→</span> |
role | Semantic role | <div role="dialog">... |
aria-expanded | Indicates expanded/collapsed state | <button aria-expanded="false">Menu</button> |
aria-haspopup | Signals a popup exists | <button aria-haspopup="menu">Options</button> |
aria-selected | Current selection in list/tabs | <button role="tab" aria-selected="true">Tab 1</button> |
aria-modal | Marks modal dialogs | <div role="dialog" aria-modal="true">... |
aria-required | Flags required form fields | <input aria-required="true" /> |
aria-invalid | Indicates validation error | <input aria-invalid="true" aria-describedby="error-msg" /> |
aria-atomic | Reads entire content block | <div aria-live="polite" aria-atomic="true">Status: Updated</div> |
4. Color Contrast & Visual Focus
WCAG Contrast Ratios:
| Level | Normal Text | Large Text (18pt+) |
|---|---|---|
| AA (minimum) | 4.5:1 | 3:1 |
| AAA (enhanced) | 7:1 | 4.5:1 |
Visual Focus Indicators (Required):
/* ✅ CORRECT: Visible focus indicator */
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* ✅ CORRECT: Alternative (background change) */
button:focus {
background-color: #e6f2ff;
box-shadow: inset 0 0 0 2px #0066cc;
}
/* ❌ WRONG: Removing focus outline */
button:focus {
outline: none; /* FORBIDDEN */
}
/* ❌ WRONG: Low contrast focus */
button:focus {
outline: 1px solid #ccc; /* Too subtle */
}
Never Convey Info by Color Alone:
<!-- ❌ WRONG: Color only -->
<span style="color: red;">This field is required</span>
<!-- ✅ CORRECT: Color + icon/text -->
<span style="color: red;">
<span aria-hidden="true">*</span> This field is required
</span>
<!-- ✅ CORRECT: Color + pattern -->
<input type="checkbox" style="border: 2px solid red;" />
<label>I agree (required)</label>
5. Testing & Validation
Automated Tools:
- axe DevTools: Browser extension for violations (Chrome, Firefox)
- Lighthouse: Chrome DevTools → Lighthouse → Accessibility (target: 90+)
- Pa11y: CLI tool for automated scanning
- jest-axe: Automated testing in Jest
Manual Testing:
- Keyboard-only: Unplug mouse; navigate using Tab, Enter, Space, arrows
- Screen readers: NVDA (Windows), JAWS (Windows), VoiceOver (macOS/iOS)
- Color contrast: WebAIM Contrast Checker
- Zoom: Test at 200% zoom; ensure nothing breaks
Acceptance Criteria:
- Zero axe DevTools violations
- Lighthouse Accessibility ≥ 90
- All features functional via keyboard
- All images have alt text
- All form inputs have labels
Component Patterns
Pattern 1: Accessible Dropdown Menu
import { useState, useRef, useEffect } from 'react';
export function AccessibleDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const buttonRef = useRef(null);
const menuRef = useRef(null);
const options = ['Edit', 'Duplicate', 'Delete'];
// Close on Escape
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
// Focus management for arrow keys
const handleMenuKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((i) => (i + 1) % options.length);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((i) => (i - 1 + options.length) % options.length);
break;
case 'Home':
e.preventDefault();
setSelectedIndex(0);
break;
case 'End':
e.preventDefault();
setSelectedIndex(options.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
handleSelect(options[selectedIndex]);
break;
default:
break;
}
};
const handleSelect = (option) => {
console.log('Selected:', option);
setIsOpen(false);
buttonRef.current?.focus();
};
return (
<div className="dropdown">
<button
ref={buttonRef}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls="dropdown-menu"
onClick={() => setIsOpen(!isOpen)}
>
Options
</button>
{isOpen && (
<ul
id="dropdown-menu"
ref={menuRef}
role="menu"
onKeyDown={handleMenuKeyDown}
>
{options.map((option, i) => (
<li key={option} role="none">
<button
role="menuitem"
aria-selected={i === selectedIndex}
onMouseEnter={() => setSelectedIndex(i)}
onClick={() => handleSelect(option)}
>
{option}
</button>
</li>
))}
</ul>
)}
</div>
);
}
Pattern 2: Accessible Modal Dialog
import { useEffect, useRef } from 'react';
export function AccessibleModal({ isOpen, onClose, title, children }) {
const dialogRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
// Store the element that opened the modal for focus restore
const previouslyFocused = document.activeElement;
// Focus the modal
dialogRef.current?.focus();
// Focus trap: trap focus inside modal
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
previouslyFocused?.focus();
return;
}
// Trap Tab inside modal
if (e.key === 'Tab') {
const focusableElements = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
className="modal"
tabIndex="-1"
>
<h2 id="modal-title">{title}</h2>
<div id="modal-description">{children}</div>
<button onClick={onClose}>Close</button>
</div>
</>
);
}
Pattern 3: Accessible Alert/Notification
export function AccessibleAlert({ type, message, onDismiss }) {
// type: 'error' | 'success' | 'warning' | 'info'
const isError = type === 'error';
return (
<div
role="alert"
aria-live={isError ? 'assertive' : 'polite'}
aria-atomic="true"
className={`alert alert-${type}`}
>
<span aria-hidden="true">
{type === 'error' && '⚠️'}
{type === 'success' && '✓'}
{type === 'warning' && '⚡'}
{type === 'info' && 'ℹ️'}
</span>
{message}
{onDismiss && (
<button
aria-label="Dismiss notification"
onClick={onDismiss}
>
×
</button>
)}
</div>
);
}
Pattern 4: Accessible Form with Error Handling
import { useState } from 'react';
export function AccessibleForm() {
const [formData, setFormData] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Email must follow format: [email protected]';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate()) {
console.log('Form submitted:', formData);
}
};
return (
<form onSubmit={handleSubmit} noValidate>
{/* Email Field */}
<div className="form-group">
<label htmlFor="email-input">
Email Address <span aria-hidden="true">*</span>
</label>
<input
id="email-input"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
{/* Password Field */}
<div className="form-group">
<label htmlFor="password-input">
Password <span aria-hidden="true">*</span>
</label>
<input
id="password-input"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
aria-required="true"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : 'password-hint'}
/>
<p id="password-hint" className="hint">
Minimum 8 characters
</p>
{errors.password && (
<span id="password-error" role="alert" className="error">
{errors.password}
</span>
)}
</div>
<button type="submit">Sign In</button>
</form>
);
}
Pattern 5: Accessible Tab Component
import { useState } from 'react';
export function AccessibleTabs() {
const [activeTab, setActiveTab] = useState(0);
const tabs = [
{ label: 'Overview', content: 'Overview content here' },
{ label: 'Details', content: 'Details content here' },
{ label: 'Related', content: 'Related items here' },
];
const handleTabKeyDown = (e, index) => {
let nextIndex = index;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
nextIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'ArrowRight':
e.preventDefault();
nextIndex = (index + 1) % tabs.length;
break;
case 'Home':
e.preventDefault();
nextIndex = 0;
break;
case 'End':
e.preventDefault();
nextIndex = tabs.length - 1;
break;
default:
return;
}
setActiveTab(nextIndex);
// Focus the newly active tab
document.getElementById(`tab-${nextIndex}`)?.focus();
};
return (
<div className="tabs">
{/* Tablist */}
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, i) => (
<button
key={i}
id={`tab-${i}`}
role="tab"
aria-selected={activeTab === i}
aria-controls={`tabpanel-${i}`}
tabIndex={activeTab === i ? 0 : -1}
onClick={() => setActiveTab(i)}
onKeyDown={(e) => handleTabKeyDown(e, i)}
>
{tab.label}
</button>
))}
</div>
{/* Tabpanels */}
{tabs.map((tab, i) => (
<div
key={i}
id={`tabpanel-${i}`}
role="tabpanel"
aria-labelledby={`tab-${i}`}
hidden={activeTab !== i}
>
{tab.content}
</div>
))}
</div>
);
}
Pattern 6: Testing with axe-core and Jest
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { AccessibleForm } from './AccessibleForm';
expect.extend(toHaveNoViolations);
describe('Accessibility Tests', () => {
test('form has no accessibility violations', async () => {
const { container } = render(<AccessibleForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('form is keyboard navigable', () => {
render(<AccessibleForm />);
const emailInput = screen.getByLabelText('Email Address');
const passwordInput = screen.getByLabelText('Password');
const submitButton = screen.getByRole('button', { name: /sign in/i });
// Tab focus order
emailInput.focus();
expect(document.activeElement).toBe(emailInput);
// Tab to password
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab' });
emailInput.dispatchEvent(tabEvent);
// Can activate button with Space/Enter
submitButton.focus();
const spaceEvent = new KeyboardEvent('keydown', { key: ' ' });
submitButton.dispatchEvent(spaceEvent);
});
test('modal traps focus', () => {
const { rerender } = render(
<AccessibleModal isOpen={true} onClose={() => {}} title="Test Modal">
<button>Action 1</button>
<button>Action 2</button>
</AccessibleModal>
);
const buttons = screen.getAllByRole('button');
const lastButton = buttons[buttons.length - 1];
// Focus trap: Tab on last button should go to first
lastButton.focus();
const tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: false,
});
expect(lastButton === document.activeElement);
});
test('alert is announced by screen readers', () => {
render(
<AccessibleAlert
type="success"
message="Item saved successfully"
/>
);
const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('aria-live', 'polite');
expect(alert).toHaveAttribute('aria-atomic', 'true');
});
});
Quick Reference Tables
Keyboard Events Mapping
| User Action | Key(s) | Component | Purpose |
|---|---|---|---|
| Navigate | Tab / Shift+Tab | All | Focus previous/next |
| Activate | Enter / Space | Button, Checkbox, Radio | Trigger action |
| Navigate list | ↑↓ | Menu, Listbox, Select | Move between items |
| Navigate horizontal | ← → | Tabs, Slider, Menu | Move between options |
| Go to start | Home | Tabs, List, Slider | Jump to first item |
| Go to end | End | Tabs, List, Slider | Jump to last item |
| Close/Cancel | Escape | Modal, Dropdown, Menu | Dismiss overlay |
| Alphabetic | a–z | Listbox, Combobox | Jump to matching item |
ARIA Role Reference (Common)
| Role | Element | Usage |
|---|---|---|
button | <div>, <span> (non-button) | Make non-button interactive |
menu / menuitem | <ul>, <li> | Popup/context menu |
listbox / option | Custom dropdown | Accessible select replacement |
tab / tablist / tabpanel | Tab UI | Tab container and panels |
dialog | Modal / overlay | Modal dialog (+ aria-modal) |
alert | Notification | Assertive/polite announcement |
region | Generic section | Landmark region (aria-label required) |
navigation | Nav links | Main/secondary navigation |
Contrast Ratio Quick Check
| Scenario | Min Ratio | Example |
|---|---|---|
| Body text (AA) | 4.5:1 | #333 on #fff = 12.6:1 ✓ |
| Large text (AA) | 3:1 | #666 on #fff = 5.74:1 ✓ |
| Icon/UI (AA) | 3:1 | #0066cc on #fff = 8.59:1 ✓ |
| Body text (AAA) | 7:1 | #555 on #fff = 9.26:1 ✓ |
Review Checklist
Use this checklist before marking a component accessible:
Semantic HTML
- Uses
<button>for buttons, not<div onclick> - Uses
<nav>,<main>,<header>,<footer>for landmarks - Heading hierarchy is correct (
<h1>→<h2>→<h3>, no skips) - All form inputs have
<label>oraria-label - Images have meaningful
altor decorativealt=""
Keyboard Navigation
- Tab order is logical (left-to-right, top-to-bottom)
- All interactive elements are reachable via Tab
- No
outline: nonewithout replacement focus indicator - Enter/Space activates buttons
- Arrow keys work in menus/lists/tabs
- Escape closes modals/dropdowns
- Focus visible with ≥2px outline or equivalent
ARIA
- ARIA attributes are correct and not redundant
-
aria-liveused for dynamic announcements -
aria-hiddenonly on decorative elements - Modal has
role="dialog"+aria-modal="true" - Form errors linked with
aria-describedby - Required fields marked with
aria-required
Visual & Color
- Text contrast ≥4.5:1 (AA) or 7:1 (AAA)
- Information not conveyed by color alone
- Focus indicators visible and distinct
- Component responsive at 200% zoom
Testing
- Axe DevTools: Zero violations
- Lighthouse Accessibility: ≥90
- Manual keyboard testing: All features work
- Screen reader tested (NVDA/VoiceOver/JAWS)
- Jest/axe unit tests included
Common Mistakes
❌ Mistake 1: Placeholder as Label
// WRONG: Placeholder disappears on focus
<input type="email" placeholder="Enter your email" />
// CORRECT: Explicit label
<label htmlFor="email">Email</label>
<input id="email" type="email" />
Why: Placeholders are hidden when users focus the input. Screen reader users won't hear what the field is for.
❌ Mistake 2: outline: none Without Replacement
/* WRONG: Keyboard users can't see focus */
button:focus {
outline: none;
}
/* CORRECT: Provide visible focus */
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Why: Keyboard users rely on focus indicators to navigate. Removing it breaks navigation.
❌ Mistake 3: Positive tabindex
// WRONG: Breaks natural tab order
<button tabindex="2">Second (confusing!)</button>
<button tabindex="1">First</button>
<button>Third (where does it go?)</button>
// CORRECT: Use tabindex 0 or -1 only
<button>First</button> // tabindex 0 (implicit)
<button>Second</button>
<button tabindex="-1">Hidden from tab order</button>
Why: Positive tabindex disrupts the natural DOM order. Screen reader and keyboard users get confused.
❌ Mistake 4: Color Alone to Convey Info
<!-- WRONG: Only color indicates required -->
<input style="border: 1px solid red;" />
<!-- CORRECT: Color + icon/text -->
<label>
Email <span aria-hidden="true">*</span>
</label>
<input />
Why: Users with color blindness can't see the distinction. Pair color with icons or text.
❌ Mistake 5: No Focus Trap in Modal
// WRONG: Focus can escape the modal
<div role="dialog">
<button>Close</button>
</div>
// CORRECT: Focus is trapped
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
// Trap focus inside modal
}
};
document.addEventListener('keydown', handleKeyDown);
}, []);
Why: Keyboard users can accidentally tab out of a modal, losing context.
❌ Mistake 6: Missing Alt Text on Images
<!-- WRONG: No alt text -->
<img src="chart.png" />
<!-- CORRECT: Meaningful alt -->
<img src="chart.png" alt="Sales increased 25% in Q3" />
<!-- CORRECT: Decorative image -->
<img src="spacer.png" alt="" aria-hidden="true" />
Why: Screen reader users hear "image, chart-dot-png" with no context. Provide descriptive alt text.
❌ Mistake 7: No Error Messages Linked to Fields
// WRONG: Error exists but not linked
<input type="email" />
<span>Invalid email format</span>
// CORRECT: Error linked with aria-describedby
<input
id="email"
type="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" role="alert">Invalid email format: [email protected]</span>
Why: Screen reader users won't know the error is related to the input. Link it explicitly.
❌ Mistake 8: No Announce for Dynamic Content
// WRONG: Item added silently
<div>Items in cart: 5</div>
// CORRECT: Announce change
<div aria-live="polite" aria-atomic="true">
Item added to cart. Items: 5
</div>
Why: Screen reader users don't hear dynamic updates. Use aria-live to announce.
Summary
Accessibility is not a feature—it's a requirement. Every component must be:
- Keyboard accessible — All features work without a mouse
- Screen reader friendly — Semantic HTML + ARIA
- Visually accessible — Sufficient contrast and focus indicators
- Well tested — Axe, Lighthouse, manual keyboard, screen reader
Start with semantic HTML. Add ARIA only when needed. Test with real tools and real users.
Last updated: 2026-03-30 Version: 1.0.0