Accessibility (a11y) patterns for this Next.js application. Covers WCAG 2.1 AA compliance, ARIA attributes, keyboard navigation, focus management, and screen reader support. Use this skill when implementing accessible UI components or validating accessibility requirements.
Patterns for implementing WCAG 2.1 Level AA compliant components with proper ARIA attributes, keyboard navigation, and screen reader support.
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)
| 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 |
// 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>
// Accordion/Collapsible
<button
aria-expanded={isOpen}
aria-controls={`content-${id}`}
onClick={() => 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>
// 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>
// 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>
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"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleAction()
}
}}
>
Interactive element
</div>
// 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>
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>
// 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>
// 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>
// 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" />
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);
}
// ✅ 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>
// 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"
onKeyDown={handleKeyDown} // Arrow keys to adjust
/>
// 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>
// core/components/ui/alert.tsx
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
eslint-plugin-jsx-a11y for lintingjest-axe for unit testingcypress-axe for E2E testingimport { 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()
})
Keyboard Navigation:
Screen Reader:
Visual:
// ❌ NEVER: Div as button
<div onClick={handleClick}>Click me</div>
// ✅ CORRECT: Use semantic button
<button onClick={handleClick}>Click 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>
Before finalizing component accessibility:
shadcn-components - Accessible UI component patternsreact-patterns - Component architecturetailwind-theming - Color contrast tokenscypress-e2e - Accessibility testing