Accessibility (a11y)
Accessibility is not optional—it's a fundamental requirement for modern web applications. This guide covers our complete accessibility strategy, ensuring our application is usable by everyone, regardless of ability or assistive technology.
📋 Table of Contents
- Why Accessibility Matters
- ARIA Attributes and Landmarks
- Keyboard Navigation
- Focus Management
- Screen Reader Support
- Color and Visual Design
- Semantic HTML
- Forms Accessibility
- Testing Accessibility
- WCAG Compliance
- Best Practices
- Common Pitfalls
Why Accessibility Matters
Statistics
- 15% of the world's population experiences some form of disability
- Over 1 billion people live with disabilities worldwide
- 100% of users benefit from accessible design (mobile users, elderly, temporary disabilities)
Benefits
✅ Legal Compliance - Meet ADA, Section 508, WCAG requirements ✅ Better SEO - Semantic HTML improves search rankings ✅ Improved UX - Keyboard navigation helps power users ✅ Wider Audience - Reach users with assistive technologies ✅ Code Quality - Forces better architecture and structure
ARIA Attributes and Landmarks
Landmark Roles
Use landmarks to define regions of your page for screen reader users:
function AppLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen">
{/* Navigation */}
<aside role="navigation" aria-label="Main navigation">
<Sidebar />
</aside>
{/* Main content */}
<main role="main" aria-labelledby="page-title">
<h1 id="page-title">Dashboard</h1>
{children}
</main>
{/* Complementary content */}
<aside role="complementary" aria-label="Related information">
<RecommendationPanel />
</aside>
</div>
)
}
Common ARIA Attributes
aria-label and aria-labelledby
// aria-label: Direct label
<button aria-label="Close dialog">
<XIcon className="h-4 w-4" />
</button>
// aria-labelledby: Reference to visible text
<section aria-labelledby="products-heading">
<h2 id="products-heading">Products</h2>
{/* Content */}
</section>
// Both together (labelledby takes precedence)
<nav aria-label="Main menu" aria-labelledby="nav-heading">
<h2 id="nav-heading">Navigation</h2>
</nav>
aria-describedby
function FormFieldWithHelp() {
const { register, formState: { errors } } = useForm()
return (
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
{...register('email')}
id="email"
aria-describedby={errors.email ? 'email-error' : 'email-help'}
/>
<p id="email-help" className="text-xs text-muted-foreground">
We'll never share your email
</p>
{errors.email && (
<p id="email-error" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
)
}
aria-live Regions
function FormWithLiveRegion() {
const [statusMessage, setStatusMessage] = useState('')
const onSubmit = async (data: FormData) => {
setStatusMessage('Submitting form...')
try {
await submitForm(data)
setStatusMessage('Form submitted successfully')
} catch (error) {
setStatusMessage('Error submitting form. Please try again.')
}
}
return (
<>
{/* Screen reader live region */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{statusMessage}
</div>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
</>
)
}
Live Region Politeness Levels:
| Value | When to Use | Behavior |
|---|---|---|
polite |
Status updates, success messages | Waits for current speech to finish |
assertive |
Errors, urgent alerts | Interrupts current speech |
off |
Default | No announcements |
aria-current
function Navigation() {
const pathname = usePathname()
return (
<nav role="navigation" aria-label="Main navigation">
{navItems.map(item => (
<Link
key={item.href}
href={item.href}
aria-current={pathname === item.href ? 'page' : undefined}
className={cn(
pathname === item.href && 'bg-accent text-accent-foreground'
)}
>
{item.name}
</Link>
))}
</nav>
)
}
aria-current Values:
page- Current page in navigationstep- Current step in a processlocation- Current location in a mapdate- Current date in a calendartime- Current time in a scheduletrue- Generic current item
aria-expanded and aria-controls
function Accordion() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls="content-1"
>
Toggle Content
{isOpen ? <ChevronUp /> : <ChevronDown />}
</button>
<div
id="content-1"
hidden={!isOpen}
role="region"
aria-labelledby="accordion-header-1"
>
<p>Content goes here</p>
</div>
</div>
)
}
aria-hidden
function IconButton() {
return (
<button aria-label="Delete item">
{/* Hide decorative icon from screen readers */}
<Trash2Icon className="h-4 w-4" aria-hidden="true" />
{/* Visible text alternative for screen readers only */}
<span className="sr-only">Delete</span>
</button>
)
}
Keyboard Navigation
Focus Indicators
Always provide visible focus indicators:
/* globals.css */
*:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* Remove default browser outline */
*:focus:not(:focus-visible) {
outline: none;
}
Tab Order
function Modal({ isOpen, onClose }: ModalProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Action</DialogTitle>
<DialogDescription>
Are you sure you want to proceed?
</DialogDescription>
</DialogHeader>
{/* Logical tab order: Cancel → Confirm → Close (X) */}
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
tabIndex={0} // First in tab order
>
Cancel
</Button>
<Button
onClick={handleConfirm}
tabIndex={0} // Second in tab order
>
Confirm
</Button>
</DialogFooter>
{/* Close button is last (handled by Radix UI) */}
</DialogContent>
</Dialog>
)
}
Skip Links
// app/layout.tsx
export default function RootLayout({ children }: LayoutProps) {
return (
<html>
<body>
{/* Skip link - first focusable element */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded"
>
Skip to main content
</a>
<Header />
<main id="main-content" tabIndex={-1}>
{children}
</main>
<Footer />
</body>
</html>
)
}
Keyboard Event Handlers
function InteractiveCard({ onClick }: InteractiveCardProps) {
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// Space or Enter activates the card
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
onClick()
}
}
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className="cursor-pointer"
>
{/* Card content */}
</div>
)
}
Escape Key Handling
function Modal({ isOpen, onClose }: ModalProps) {
useEffect(() => {
if (!isOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, onClose])
// Modal JSX
}
Focus Management
Focus Trap in Modals
import { useEffect, useRef } from 'react'
function useF focusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isActive || !containerRef.current) return
const container = containerRef.current
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0] as HTMLElement
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement
// Focus first element on mount
firstElement?.focus()
const handleTabKey = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault()
lastElement?.focus()
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault()
firstElement?.focus()
}
}
}
document.addEventListener('keydown', handleTabKey)
return () => document.removeEventListener('keydown', handleTabKey)
}, [isActive])
return containerRef
}
// Usage
function Modal({ isOpen, onClose }: ModalProps) {
const modalRef = useFocusTrap(isOpen)
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent ref={modalRef}>
{/* Modal content */}
</DialogContent>
</Dialog>
)
}
Restore Focus After Modal Close
function useRestoreFocus(isOpen: boolean) {
const previousActiveElement = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
// Store currently focused element
previousActiveElement.current = document.activeElement as HTMLElement
} else {
// Restore focus when modal closes
previousActiveElement.current?.focus()
previousActiveElement.current = null
}
}, [isOpen])
}
Programmatic Focus
function SearchDialog() {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
// Focus search input when dialog opens
inputRef.current?.focus()
}, [])
return (
<DialogContent>
<Input
ref={inputRef}
type="search"
placeholder="Search..."
aria-label="Search products"
/>
</DialogContent>
)
}
Screen Reader Support
Screen Reader Only Text
/* globals.css */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.sr-only:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
Usage Examples
// Icon-only button
<button aria-label="Settings">
<SettingsIcon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Settings</span>
</button>
// Loading indicator
<button disabled>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<span className="sr-only">Loading...</span>
</button>
// Status badge
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-green-500" aria-hidden="true" />
<span>Active</span>
<span className="sr-only">Status: Active</span>
</span>
Accessible Names
// ❌ WRONG - No accessible name
<button>
<TrashIcon />
</button>
// ✅ CORRECT - aria-label
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
// ✅ CORRECT - Visible text
<button>
<TrashIcon aria-hidden="true" />
<span>Delete</span>
</button>
// ✅ CORRECT - aria-labelledby
<button aria-labelledby="delete-label">
<TrashIcon aria-hidden="true" />
<span id="delete-label">Delete item</span>
</button>
Color and Visual Design
Color Contrast Requirements
WCAG 2.1 Level AA (Minimum):
- Normal text (< 18pt): Contrast ratio ≥ 4.5:1
- Large text (≥ 18pt or 14pt bold): Contrast ratio ≥ 3:1
- UI components and graphics: Contrast ratio ≥ 3:1
WCAG 2.1 Level AAA (Enhanced):
- Normal text: Contrast ratio ≥ 7:1
- Large text: Contrast ratio ≥ 4.5:1
Checking Contrast
// Use CSS variables for theme colors
// These are defined with proper contrast ratios
function Button({ variant = 'default' }: ButtonProps) {
return (
<button
className={cn(
// Default: bg-primary + text-primary-foreground (4.5:1 ratio)
variant === 'default' && 'bg-primary text-primary-foreground',
// Destructive: bg-destructive + text-destructive-foreground (4.5:1 ratio)
variant === 'destructive' && 'bg-destructive text-destructive-foreground',
// Outline: border + text maintain 4.5:1 against background
variant === 'outline' && 'border border-input bg-background hover:bg-accent'
)}
>
{children}
</button>
)
}
Color Is Not the Only Visual Indicator
// ❌ WRONG - Color only
<div>
<span className="text-red-500">Error</span>
<span className="text-green-500">Success</span>
</div>
// ✅ CORRECT - Color + icon + text
<div className="space-y-2">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Something went wrong</AlertDescription>
</Alert>
<Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertTitle>Success</AlertTitle>
<AlertDescription>Action completed successfully</AlertDescription>
</Alert>
</div>
Focus Indicators
// ✅ CORRECT - Always visible focus indicator
<Button className="focus:ring-2 focus:ring-ring focus:ring-offset-2">
Click me
</Button>
// ❌ WRONG - No focus indicator
<Button className="focus:outline-none">
Click me
</Button>
Semantic HTML
Use Correct HTML Elements
// ❌ WRONG - div as button
<div onClick={handleClick} className="cursor-pointer">
Click me
</div>
// ✅ CORRECT - button element
<button onClick={handleClick}>
Click me
</button>
// ❌ WRONG - div for navigation
<div>
<a href="/home">Home</a>
<a href="/about">About</a>
</div>
// ✅ CORRECT - nav element
<nav>
<a href="/home">Home</a>
<a href="/about">About</a>
</nav>
Heading Hierarchy
// ✅ CORRECT - Logical heading hierarchy
<article>
<h1>Article Title</h1>
<section>
<h2>Section 1</h2>
<p>Content...</p>
<h3>Subsection 1.1</h3>
<p>Content...</p>
</section>
<section>
<h2>Section 2</h2>
<p>Content...</p>
</section>
</article>
// ❌ WRONG - Skipping heading levels
<article>
<h1>Article Title</h1>
<h4>Section</h4> {/* Skipped h2, h3 */}
</article>
Lists
// ✅ CORRECT - Semantic lists
<nav aria-label="Main navigation">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
// Feature list
<section aria-labelledby="features-heading">
<h2 id="features-heading">Features</h2>
<ul>
<li>Fast performance</li>
<li>Secure by default</li>
<li>Easy to use</li>
</ul>
</section>
Forms Accessibility
Label Association
// ✅ CORRECT - Explicit label association
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
name="email"
/>
</div>
// ❌ WRONG - No label association
<div>
<div>Email</div>
<Input type="email" />
</div>
Required Fields
<div className="space-y-2">
<Label htmlFor="email">
Email
<span className="text-destructive ml-1" aria-label="required">*</span>
</Label>
<Input
id="email"
type="email"
required
aria-required="true"
/>
</div>
Error Messages
function FormField() {
const { register, formState: { errors } } = useForm()
return (
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
{...register('email')}
id="email"
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p
id="email-error"
role="alert"
aria-live="assertive"
className="text-sm text-destructive"
>
{errors.email.message}
</p>
)}
</div>
)
}
Fieldsets and Legends
<fieldset>
<legend>Shipping Address</legend>
<div className="grid gap-4">
<div>
<Label htmlFor="street">Street</Label>
<Input id="street" name="street" />
</div>
<div>
<Label htmlFor="city">City</Label>
<Input id="city" name="city" />
</div>
</div>
</fieldset>
Testing Accessibility
Automated Testing
ESLint Plugin
// .eslintrc.js
module.exports = {
extends: [
'plugin:jsx-a11y/recommended'
],
plugins: ['jsx-a11y'],
rules: {
'jsx-a11y/anchor-is-valid': 'error',
'jsx-a11y/alt-text': 'error',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-role': 'error',
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/label-has-associated-control': 'error',
}
}
Jest with jest-axe
import { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'
import { Button } from './button'
expect.extend(toHaveNoViolations)
describe('Button Accessibility', () => {
it('should not have accessibility violations', async () => {
const { container } = render(
<Button>Click me</Button>
)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
it('should have accessible name', () => {
const { getByRole } = render(
<Button aria-label="Close dialog">
<XIcon />
</Button>
)
expect(getByRole('button')).toHaveAccessibleName('Close dialog')
})
})
Cypress with cypress-axe
// cypress/support/commands.ts
import 'cypress-axe'
// In test
describe('Login Page', () => {
beforeEach(() => {
cy.visit('/login')
cy.injectAxe()
})
it('should not have accessibility violations', () => {
cy.checkA11y()
})
it('should have proper heading structure', () => {
cy.checkA11y(null, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa']
}
})
})
})
Manual Testing
Keyboard Navigation Checklist
- All interactive elements are keyboard accessible
- Tab order is logical
- Focus indicators are visible
- Skip links work correctly
- Modals trap focus
- Escape key closes modals/dropdowns
Screen Reader Testing
Tools:
- macOS: VoiceOver (Cmd + F5)
- Windows: NVDA (free), JAWS (paid)
- Linux: Orca
Testing Checklist:
- All images have alt text
- Form labels are announced
- Error messages are announced
- Status updates are announced (aria-live)
- Navigation landmarks are identified
- Heading hierarchy is logical
WCAG Compliance
WCAG 2.1 Levels
| Level | Requirement | Description |
|---|---|---|
| A | Minimum | Basic accessibility features |
| AA | Mid-range | Industry standard (most laws require this) |
| AAA | Highest | Enhanced accessibility |
WCAG 2.1 Principles (POUR)
1. Perceivable
Information must be presentable to users in ways they can perceive:
✅ Text alternatives for images ✅ Captions for videos ✅ Adaptable content structure ✅ Sufficient color contrast ✅ Resizable text
2. Operable
Interface components must be operable:
✅ Keyboard accessible ✅ Enough time to read/use content ✅ No seizure-inducing flashing content ✅ Navigable structure ✅ Multiple input methods
3. Understandable
Information and operation must be understandable:
✅ Readable text ✅ Predictable behavior ✅ Input assistance ✅ Error identification ✅ Error prevention
4. Robust
Content must be robust enough to work with assistive technologies:
✅ Valid HTML ✅ ARIA compliance ✅ Compatible with current and future tools
Best Practices
1. Use Radix UI Primitives
✅ CORRECT:
import { Dialog } from '@radix-ui/react-dialog'
// Radix handles all accessibility automatically
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogTitle>Title</DialogTitle>
<DialogDescription>Description</DialogDescription>
</DialogContent>
</Dialog>
2. Test with Real Users
- Include users with disabilities in testing
- Use actual assistive technologies
- Don't rely solely on automated tools
3. Provide Text Alternatives
// Images
<Image
src="/product.jpg"
alt="Blue running shoes with white laces"
width={400}
height={300}
/>
// Icons
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
// Videos
<video controls>
<source src="video.mp4" type="video/mp4" />
<track
kind="captions"
src="captions.vtt"
srclang="en"
label="English"
/>
</video>
4. Mobile Accessibility
// Touch targets minimum 44x44px
<button className="min-h-[44px] min-w-[44px] p-2">
<Icon className="h-5 w-5" />
</button>
// Zoom support
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=5"
/>
Common Pitfalls
❌ WRONG Patterns
// 1. Missing alt text
<img src="product.jpg" />
// 2. Empty button
<button onClick={handleClick}>
<Icon />
</button>
// 3. div as button
<div onClick={handleClick}>Click me</div>
// 4. Poor color contrast
<button className="bg-gray-200 text-gray-300">
Low contrast
</button>
// 5. No focus indicator
<button className="focus:outline-none">
Click me
</button>
// 6. Keyboard trap
<div onKeyDown={(e) => e.preventDefault()}>
Content
</div>
✅ CORRECT Patterns
// 1. Descriptive alt text
<img src="product.jpg" alt="Blue running shoes with white laces" />
// 2. Accessible button
<button onClick={handleClick} aria-label="Close dialog">
<XIcon aria-hidden="true" />
</button>
// 3. Semantic button
<button onClick={handleClick}>Click me</button>
// 4. Good contrast
<button className="bg-primary text-primary-foreground">
High contrast
</button>
// 5. Visible focus
<button className="focus:ring-2 focus:ring-ring">
Click me
</button>
// 6. Proper keyboard handling
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick()
}
}}
>
Content
</div>
Related Documentation
- Forms and Validation - Form accessibility patterns
- Component Architecture - Semantic component structure
- shadcn/ui Integration - Accessible component library
Resources
Tools
- axe DevTools Browser Extension - Free accessibility testing
- WAVE Browser Extension - Visual accessibility analysis
- Lighthouse - Built into Chrome DevTools
- Color Contrast Analyzer - Check contrast ratios
Documentation
- WCAG 2.1 Guidelines
- MDN Accessibility Guide
- Radix UI Accessibility
- WebAIM - Comprehensive accessibility resource
- A11y Project - Community-driven accessibility resource
Testing
- jest-axe - Automated accessibility testing
- cypress-axe - Cypress accessibility plugin
- pa11y - Automated accessibility testing tool