Web Accessibility Standards for Next.js
Comprehensive guide to building accessible Next.js applications that work for everyone, including people with disabilities.
WCAG 2.1 AA Overview
WCAG (Web Content Accessibility Guidelines) Level AA is the industry standard for web accessibility. It covers:
- •Perceivable: Information must be perceivable (not invisible to all senses)
- •Operable: Interface must be operable via keyboard, not just mouse
- •Understandable: Information and operation must be understandable
- •Robust: Content works with assistive technologies (screen readers, voice control, etc.)
Semantic HTML
Using semantic HTML is the foundation of accessibility.
Heading Hierarchy
// GOOD: Proper heading hierarchy
export default function BlogPost() {
return (
<>
<h1>Blog Title</h1>
<p>Introduction paragraph</p>
<h2>First Section</h2>
<p>Content...</p>
<h3>Subsection</h3>
<p>Content...</p>
<h2>Second Section</h2>
<p>Content...</p>
</>
)
}
// BAD: Skipping heading levels
<h1>Title</h1>
<h3>Section</h3> {/* Jumps from h1 to h3 */}
Semantic Landmarks
// GOOD: Using semantic elements
export default function Layout({ children }) {
return (
<>
<header>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main>{children}</main>
<aside>
<h2>Related Content</h2>
</aside>
<footer>
<p>© 2024 Company Name</p>
</footer>
</>
)
}
// BAD: Using divs instead of semantic elements
<div className="header">
<div className="nav">
<div className="nav-list">
<div><a href="/">Home</a></div>
</div>
</div>
</div>
Lists
// GOOD: Use <ul> and <ol> for lists <ul> <li>First item</li> <li>Second item</li> <li>Third item</li> </ul> // BAD: Using divs or manually styled lists <div> <div>First item</div> <div>Second item</div> <div>Third item</div> </div>
Tables
// GOOD: Proper table structure with headers
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td>John Doe</td>
<td>john@example.com</td>
<td>Admin</td>
</tr>
</tbody>
</table>
// GOOD: Table caption
<table>
<caption>User Directory</caption>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{/* ... */}
</tbody>
</table>
ARIA (Accessible Rich Internet Applications)
ARIA provides additional semantics when native HTML doesn't suffice. However, prefer native HTML first.
Key Principle: Prefer Native HTML
// GOOD: Use native button
<button onClick={handleClick}>Submit</button>
// BAD: Fake button with ARIA
<div
role="button"
onClick={handleClick}
onKeyDown={...}
tabIndex={0}
aria-pressed={false}
>
Submit
</div>
ARIA Attributes
Roles
// Dialog role <div role="dialog" aria-modal="true" aria-labelledby="dialog-title"> <h2 id="dialog-title">Confirm Action</h2> <p>Are you sure?</p> </div> // Alert role (announces to screen readers) <div role="alert" aria-live="polite"> Error: Please fill all required fields </div>
States
// aria-expanded for collapsible content
<button
aria-expanded={isOpen}
aria-controls="menu"
onClick={toggle}
>
Menu
</button>
<div id="menu" hidden={!isOpen}>
{/* Menu items */}
</div>
// aria-disabled for disabled state
<button aria-disabled={isLoading}>
{isLoading ? 'Loading...' : 'Submit'}
</button>
// aria-checked for checkboxes
<div
role="checkbox"
aria-checked={isChecked}
onClick={toggle}
>
I agree to terms
</div>
Descriptions
// aria-label: Label for element without visible text
<button aria-label="Close menu">×</button>
// aria-labelledby: Label from another element
<h2 id="section-title">Important Section</h2>
<div role="region" aria-labelledby="section-title">
{/* Content */}
</div>
// aria-describedby: Additional description
<input
id="password"
aria-describedby="password-hint"
type="password"
/>
<small id="password-hint">
Must be 8+ characters with numbers and symbols
</small>
Keyboard Navigation
All interactive elements must be accessible via keyboard.
Focus Management
'use client'
import { useRef } from 'react'
export function Dialog({ isOpen, onClose }) {
const firstButtonRef = useRef<HTMLButtonElement>(null)
// Move focus to dialog when it opens
useEffect(() => {
if (isOpen) {
firstButtonRef.current?.focus()
}
}, [isOpen])
return (
isOpen && (
<div
role="dialog"
aria-modal="true"
onKeyDown={(e) => {
// Close on Escape
if (e.key === 'Escape') onClose()
}}
>
<h2>Confirm Action</h2>
<p>Are you sure?</p>
<button ref={firstButtonRef}>Yes</button>
<button onClick={onClose}>No</button>
</div>
)
)
}
Tab Order
// GOOD: Natural tab order (top to bottom)
<form>
<input name="firstName" />
<input name="lastName" />
<button>Submit</button>
</form>
// AVOID: Using tabIndex > 0
<div tabIndex={1}>First in tab order</div>
<div tabIndex={2}>Second in tab order</div>
<div tabIndex={0}>Should be first but appears last</div>
Skip Links
export default function Layout({ children }) {
return (
<>
<a href="#main-content" className="sr-only">
Skip to main content
</a>
<header>
{/* Navigation */}
</header>
<main id="main-content">
{children}
</main>
</>
)
}
// CSS to hide visually but show for screen readers
.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;
}
Focus Management in SPAs
Single Page Applications need special focus management when content changes.
Route Changes
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
export function Page() {
const router = useRouter()
const mainRef = useRef<HTMLElement>(null)
useEffect(() => {
// Move focus to main content when route changes
mainRef.current?.focus()
}, [router])
return (
<main ref={mainRef} tabIndex={-1}>
{/* Content */}
</main>
)
}
Modal Opens
export function Modal({ isOpen, onClose }) {
const triggerRef = useRef<HTMLButtonElement>(null)
const firstContentRef = useRef<HTMLButtonElement>(null)
return (
<>
<button ref={triggerRef} onClick={() => setOpen(true)}>
Open Modal
</button>
{isOpen && (
<div role="dialog" aria-modal="true">
<button ref={firstContentRef}>Close</button>
{/* Modal content */}
</div>
)}
</>
)
}
Images and Alt Text
Proper alt text is critical for accessibility.
Informative Images
// GOOD: Descriptive alt text
<Image
src="/chart.jpg"
alt="Sales increased 25% in Q3"
width={400}
height={300}
/>
// GOOD: For product images
<Image
src="/product.jpg"
alt="Blue ceramic mug with white handle"
width={400}
height={400}
/>
Decorative Images
// GOOD: Empty alt text for decorative images
<Image
src="/divider.jpg"
alt=""
width={400}
height={10}
/>
// Good: Using aria-hidden
<div aria-hidden="true">
<Image src="/background.jpg" alt="" />
</div>
Complex Images
// GOOD: Caption for complex images
<figure>
<Image
src="/diagram.jpg"
alt="System architecture diagram"
width={600}
height={400}
/>
<figcaption>
Shows how the frontend communicates with the backend
through REST APIs
</figcaption>
</figure>
// GOOD: Long description for very complex images
<Image
src="/complex-chart.jpg"
alt="Revenue by product line"
width={800}
height={600}
aria-describedby="chart-description"
/>
<div id="chart-description" className="sr-only">
The chart shows revenue trends from 2020 to 2024...
</div>
Forms
Accessible forms are crucial for usability.
Labels
// GOOD: Label associated with input <label htmlFor="email">Email Address</label> <input id="email" type="email" /> // BAD: Placeholder instead of label <input placeholder="Email Address" type="email" />
Error Messages
export function LoginForm() {
const [errors, setErrors] = useState<Record<string, string>>({})
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<div id="email-error" role="alert">
{errors.email}
</div>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<div id="password-error" role="alert">
{errors.password}
</div>
)}
</div>
<button type="submit">Sign In</button>
</form>
)
}
Required Fields
// GOOD: Indicate required fields <label htmlFor="username"> Username <span aria-label="required">*</span> </label> <input id="username" required /> // Also use HTML required attribute <input id="username" required aria-required="true" />
Inline Validation
<div>
<label htmlFor="zip">ZIP Code</label>
<input
id="zip"
type="text"
pattern="\d{5}"
aria-describedby="zip-format"
/>
<div id="zip-format">Format: 12345</div>
</div>
Color and Contrast
Color must not be the only way to convey information.
Contrast Ratios
// WCAG AA requires:
// - 4.5:1 for normal text
// - 3:1 for large text (18pt+)
// GOOD: High contrast
<div style={{ color: '#000', backgroundColor: '#fff' }}>
Black text on white background
</div>
// BAD: Low contrast
<div style={{ color: '#999', backgroundColor: '#f5f5f5' }}>
Gray text on light gray background
</div>
Using Color
// BAD: Relying only on color
<div className="flex gap-4">
<div className="w-12 h-12 bg-red-500" />
<div className="w-12 h-12 bg-green-500" />
<div className="w-12 h-12 bg-blue-500" />
</div>
// GOOD: Color + another indicator
<div className="flex gap-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-red-500 rounded-full" />
<span>Error</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-green-500 rounded-full" />
<span>Success</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-blue-500 rounded-full" />
<span>Info</span>
</div>
</div>
Motion and Animation
Some users have vestibular disorders or motion sensitivity.
Respecting Preferences
// CSS: Respect prefers-reduced-motion
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
// React: Disable animations based on preference
export function AnimatedComponent() {
const prefersReducedMotion = useMediaQuery(
'(prefers-reduced-motion: reduce)'
)
if (prefersReducedMotion) {
return <div>Content without animation</div>
}
return (
<motion.div
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
Content with animation
</motion.div>
)
}
Screen Reader Testing
Testing Tools
- •NVDA (Windows): Free, open-source screen reader
- •JAWS (Windows): Industry standard, paid
- •VoiceOver (macOS): Built-in, activate with Cmd+F5
- •TalkBack (Android): Built-in screen reader
- •VoiceOver (iOS): Built-in screen reader
Testing Checklist
- • All buttons have accessible names
- • All links have descriptive text
- • Images have appropriate alt text
- • Form labels are associated with inputs
- • Error messages are announced
- • Page structure is logical with headings
- • Interactive elements are keyboard accessible
- • Focus is visible when tabbing
- • Modals trap focus properly
- • Dynamic content updates are announced
Automated Testing
Use automated tools to catch common issues.
ESLint Plugin
npm install -D eslint-plugin-jsx-a11y
Configuration
// .eslintrc.json
{
"extends": ["plugin:jsx-a11y/recommended"],
"plugins": ["jsx-a11y"]
}
axe-core for Testing
npm install -D @axe-core/react
import { render } from '@testing-library/react'
import { axe } from 'jest-axe'
test('Button has no accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
Accessibility Checklist
See references/a11y-checklist.md for a detailed component-by-component checklist.
ARIA Patterns Reference
See references/aria-patterns.md for common ARIA patterns with examples.
Additional Resources
- •WCAG 2.1 Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- •WebAIM: https://webaim.org/ (Tutorials and resources)
- •MDN Accessibility: https://developer.mozilla.org/en-US/docs/Web/Accessibility
- •Deque University: https://dequeuniversity.com/ (Free courses)
- •The A11Y Project: https://www.a11yproject.com/ (Community resources)