Accessibility Skill
Patterns for implementing WCAG 2.1 Level AA compliant components with proper ARIA attributes, keyboard navigation, and screen reader support.
Architecture Overview
ACCESSIBILITY LAYERS:
Semantic HTML Foundation:
├── <button> for buttons (not <div>)
├── <nav> for navigation regions
├── <form> and <fieldset> for forms
├── <a> for links
└── Heading hierarchy (h1-h6)
ARIA Enhancement:
├── Landmark roles (navigation, main, complementary)
├── State attributes (aria-expanded, aria-current)
├── Relationship attributes (aria-labelledby, aria-describedby)
└── Live regions (aria-live, role="alert")
Visual Accessibility:
├── Color contrast (4.5:1 minimum)
├── Focus indicators (focus-visible:ring-2)
├── Motion reduction (prefers-reduced-motion)
└── Text scaling support
Keyboard Accessibility:
├── Tab order management
├── Focus trapping in modals
├── Escape key handling
└── Arrow key navigation (where appropriate)
When to Use This Skill
- Implementing interactive UI components
- Adding ARIA attributes to custom elements
- Validating accessibility requirements
- Ensuring keyboard navigation works
- Testing with screen readers
WCAG Compliance Standards
Level AA Requirements (Minimum)
| Criterion | Requirement | Implementation |
|---|---|---|
| 1.4.3 Color Contrast | 4.5:1 for normal text, 3:1 for large text | Use semantic color tokens |
| 1.4.11 Non-text Contrast | 3:1 for UI components | Focus rings, borders |
| 2.1.1 Keyboard | All functionality via keyboard | Tab navigation, Enter/Space |
| 2.4.3 Focus Order | Logical focus sequence | DOM order, tabIndex |
| 2.4.7 Focus Visible | Visible focus indicator | focus-visible:ring-2 |
| 4.1.2 Name, Role, Value | Accessible names for elements | Labels, aria-label |
POUR Principles
- Perceivable: Content visible to all users (contrast, alt text)
- Operable: Interface usable via keyboard and assistive tech
- Understandable: Clear, predictable interface
- Robust: Compatible with current and future assistive tech
ARIA Attribute Patterns
Landmark Roles
// Navigation with accessible label
<nav role="navigation" aria-label="pagination">
<ul>
<li><a href="/page/1">1</a></li>
<li><a href="/page/2" aria-current="page">2</a></li>
</ul>
</nav>
// Breadcrumb navigation
<nav aria-label="breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li aria-current="page">Products</li>
</ol>
</nav>
// Main content area
<main role="main" id="main-content">
{/* Page content */}
</main>
Dynamic State Attributes
// Accordion/Collapsible
<button
aria-expanded={isOpen}
aria-controls={`content-${id}`}
=> setIsOpen(!isOpen)}
>
Section Title
</button>
<div
id={`content-${id}`}
aria-hidden={!isOpen}
hidden={!isOpen}
>
{/* Collapsible content */}
</div>
// Tabs
<div role="tablist" aria-label="Settings tabs">
<button
role="tab"
aria-selected={activeTab === 'general'}
aria-controls="panel-general"
tabIndex={activeTab === 'general' ? 0 : -1}
>
General
</button>
</div>
<div
role="tabpanel"
id="panel-general"
aria-labelledby="tab-general"
hidden={activeTab !== 'general'}
>
{/* Tab content */}
</div>
// Menu/Dropdown
<button
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls="menu-items"
>
Options
</button>
<div
id="menu-items"
role="menu"
aria-hidden={!isOpen}
>
<button role="menuitem">Edit</button>
<button role="menuitem">Delete</button>
</div>
Form Accessibility
// core/components/ui/form.tsx pattern
<FormItem>
<FormLabel htmlFor={formItemId}>
Email
</FormLabel>
<FormControl>
<Input
id={formItemId}
aria-describedby={
!error
? formDescriptionId
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
aria-required={required}
/>
</FormControl>
<FormDescription id={formDescriptionId}>
We'll never share your email.
</FormDescription>
{error && (
<FormMessage id={formMessageId} role="alert">
{error.message}
</FormMessage>
)}
</FormItem>
Live Regions
// Status announcements (polite)
<div aria-live="polite" aria-atomic="true" className="sr-only">
{statusMessage}
</div>
// Error alerts (assertive)
<div role="alert" className="text-destructive">
{errorMessage}
</div>
// Toast notifications
<div
role="status"
aria-live="polite"
aria-atomic="true"
>
{notification}
</div>
Keyboard Navigation Patterns
Focus Indicators
All interactive elements use the standardized focus ring:
// Button focus pattern
<button
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Click me
</button>
// Input focus pattern
<input
className="focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
// Custom focus indicator
<div
tabIndex={0}
className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
=> {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleAction()
}
}}
>
Interactive element
</div>
Tab Order Management
// Logical tab order (follows DOM)
<header>
<nav tabIndex={0}>...</nav> {/* First */}
</header>
<main tabIndex={0}>...</main> {/* Second */}
<aside tabIndex={0}>...</aside> {/* Third */}
// Remove from tab order
<div tabIndex={-1}>
{/* Programmatically focusable but not in tab order */}
</div>
// Skip link pattern
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-background focus:px-4 focus:py-2"
>
Skip to main content
</a>
Focus Trap (Modals)
Radix UI handles focus trapping automatically in dialogs:
// core/components/ui/dialog.tsx
import * as DialogPrimitive from '@radix-ui/react-dialog'
// Radix Dialog automatically:
// - Traps focus within modal
// - Returns focus on close
// - Handles Escape key
// - Manages aria-hidden on background
<DialogPrimitive.Root>
<DialogPrimitive.Trigger>Open</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay />
<DialogPrimitive.Content>
{/* Focus is trapped here */}
<DialogPrimitive.Close>
<span className="sr-only">Close</span>
<X aria-hidden="true" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
Screen Reader Support
Screen Reader Only Text
// Visually hidden but announced by screen readers
<span className="sr-only">Close dialog</span>
// Icon button with accessible name
<Button variant="ghost" size="icon">
<X aria-hidden="true" />
<span className="sr-only">Close</span>
</Button>
// More pages indicator
<span aria-hidden="true">...</span>
<span className="sr-only">More pages</span>
Decorative Icons
// Hide decorative icons from screen readers
<ChevronRight aria-hidden="true" className="h-4 w-4" />
// Separator in breadcrumb
<span role="presentation" aria-hidden="true">/</span>
// Loading spinner
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin"
/>
<span className="sr-only">Loading...</span>
Accessible Names Strategy
// 1. Visible text (preferred)
<button>Delete Item</button>
// 2. aria-label for icon-only buttons
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
// 3. aria-labelledby for complex labels
<div id="dialog-title">Confirm Deletion</div>
<div aria-labelledby="dialog-title">
{/* Content described by title */}
</div>
// 4. Form labels
<Label htmlFor="email">Email address</Label>
<Input id="email" type="email" />
Color Contrast
OKLCH Color System
The theme uses OKLCH color space for perceptually uniform colors:
/* contents/themes/default/styles/globals.css */
:root {
/* High contrast pairs */
--primary: oklch(0.2050 0 0); /* Dark */
--primary-foreground: oklch(0.9850 0 0); /* Light - 4.5:1+ contrast */
--destructive: oklch(0.5770 0.2450 27.3250);
--destructive-foreground: oklch(1 0 0);
/* Focus ring with sufficient contrast */
--ring: oklch(0.7080 0 0);
}
.dark {
/* Inverted for dark mode */
--primary: oklch(0.9220 0 0);
--primary-foreground: oklch(0.2050 0 0);
}
Semantic Color Usage
// ✅ CORRECT - Use semantic tokens that guarantee contrast
<p className="text-foreground">Primary text</p>
<p className="text-muted-foreground">Secondary text</p>
<button className="bg-primary text-primary-foreground">
Action
</button>
// ❌ WRONG - Arbitrary colors may not have proper contrast
<p className="text-gray-400">Low contrast text</p>
<button className="bg-blue-300 text-blue-100">
Poor contrast
</button>
Component Patterns
Slider/Range Component
// core/components/ui/double-range.tsx
<div
ref={thumbMinRef}
role="slider"
aria-label="Minimum value"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={localValue[0]}
tabIndex={disabled ? -1 : 0}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
// Arrow keys to adjust
/>
Pagination Component
// core/components/ui/pagination.tsx
<nav role="navigation" aria-label="pagination">
<PaginationPrevious aria-label="Go to previous page" />
{pages.map((page) => (
<PaginationLink
aria-current={page === current ? 'page' : undefined}
>
{page}
</PaginationLink>
))}
<PaginationNext aria-label="Go to next page" />
</nav>
Alert Component
// core/components/ui/alert.tsx
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
Testing Accessibility
Tools
- ESLint Plugin:
eslint-plugin-jsx-a11yfor linting - Jest:
jest-axefor unit testing - Cypress:
cypress-axefor E2E testing - Manual: Keyboard navigation, screen reader testing
Jest-axe Example
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
test('should have no accessibility violations', async () => {
const { container } = render(<MyComponent />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
Manual Testing Checklist
Keyboard Navigation:
- Tab through all interactive elements
- Verify logical focus order
- Escape key closes modals
- Enter/Space activates buttons
Screen Reader:
- VoiceOver (Mac) or NVDA (Windows)
- Verify content is announced correctly
- Check form labels and error messages
- Verify live regions announce updates
Visual:
- Check focus indicators are visible
- Verify contrast with browser devtools
- Test with zoom (200%)
Anti-Patterns
// ❌ NEVER: Div as button
<div me</div>
// ✅ CORRECT: Use semantic button
<button me</button>
// ❌ NEVER: Icon-only button without accessible name
<button><TrashIcon /></button>
// ✅ CORRECT: Add aria-label or sr-only text
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
// ❌ NEVER: Remove focus indicator completely
<button className="outline-none focus:outline-none">
No focus indicator
</button>
// ✅ CORRECT: Custom focus indicator
<button className="focus-visible:ring-2 focus-visible:ring-ring">
Visible focus
</button>
// ❌ NEVER: Low contrast text
<p className="text-gray-300 bg-white">Hard to read</p>
// ✅ CORRECT: Use semantic tokens
<p className="text-muted-foreground bg-background">Readable</p>
// ❌ NEVER: Missing form labels
<input type="email" placeholder="Email" />
// ✅ CORRECT: Proper label association
<Label htmlFor="email">Email</Label>
<input id="email" type="email" />
// ❌ NEVER: Images without alt text
<img src="/logo.png" />
// ✅ CORRECT: Descriptive alt or empty for decorative
<img src="/logo.png" alt="Company Logo" />
<img src="/decorative.png" alt="" aria-hidden="true" />
// ❌ NEVER: Auto-playing media without controls
<video autoPlay src="/video.mp4" />
// ✅ CORRECT: User controls and captions
<video controls>
<source src="/video.mp4" />
<track kind="captions" src="/captions.vtt" />
</video>
Checklist
Before finalizing component accessibility:
- Semantic HTML elements used (button, nav, form, etc.)
- All interactive elements keyboard accessible
- Focus indicators visible (focus-visible:ring-2)
- ARIA attributes correct (aria-expanded, aria-controls, etc.)
- Form fields have associated labels
- Error messages use role="alert"
- Icons have aria-hidden="true"
- Icon-only buttons have aria-label or sr-only text
- Color contrast meets 4.5:1 (text) / 3:1 (UI)
- Screen reader text (.sr-only) where needed
- Tab order is logical
- Modals trap focus and return it on close
- Live regions announce dynamic content
Related Skills
shadcn-components- Accessible UI component patternsreact-patterns- Component architecturetailwind-theming- Color contrast tokenscypress-e2e- Accessibility testing