Web accessibility ensures that people with disabilities can effectively use your website. With Next.js, you have powerful tools to build inclusive applications that work for everyone. This guide covers essential accessibility practices with practical examples.
Why Accessibility Matters
Approximately 15% of the world's population experiences some form of disability. Accessible websites benefit everyone, including users with:
- Visual impairments (blindness, low vision, color blindness)
- Hearing impairments
- Motor disabilities
- Cognitive disabilities
- Temporary disabilities (broken arm, bright sunlight)
Semantic HTML in Next.js
Using proper HTML elements is the foundation of accessibility. Next.js components should use semantic HTML rather than generic divs.
Bad Example
// app/components/Header.jsx
export default function Header() {
return (
<div className="header">
<div className="logo">My Site</div>
<div className="menu">
<div onClick={() => navigate('/')}>Home</div>
<div onClick={() => navigate('/about')}>About</div>
</div>
</div>
);
}
Good Example
// app/components/Header.jsx
import Link from 'next/link';
export default function Header() {
return (
<header className="header">
<h1 className="logo">My Site</h1>
<nav aria-label="Main navigation">
<ul>
<li><Link href="/">Home</Link></li>
<li><Link href="/about">About</Link></li>
</ul>
</nav>
</header>
);
}
Next.js Link Component for Navigation
The Next.js Link
component is accessible by default, providing proper keyboard navigation and screen reader support.
// app/components/Navigation.jsx
import Link from 'next/link';
export default function Navigation() {
return (
<nav aria-label="Primary">
<Link href="/products" className="nav-link">
Products
</Link>
<Link
href="/contact"
className="nav-link"
aria-label="Contact us for support"
>
Contact
</Link>
</nav>
);
}
Managing Focus with Next.js
When navigation occurs, screen reader users need proper focus management.
// app/components/SkipLink.jsx
export default function SkipLink() {
return (
<a
href="#main-content"
className="skip-link"
style={{
position: 'absolute',
left: '-9999px',
top: '0',
}}
onFocus={(e) => {
e.currentTarget.style.left = '0';
}}
onBlur={(e) => {
e.currentTarget.style.left = '-9999px';
}}
>
Skip to main content
</a>
);
}
// app/layout.jsx
import SkipLink from './components/SkipLink';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<SkipLink />
<Header />
<main id="main-content" tabIndex="-1">
{children}
</main>
<Footer />
</body>
</html>
);
}
Form Accessibility
Forms require proper labels, error messages, and validation feedback.
// app/components/ContactForm.jsx
'use client';
import { useState } from 'react';
export default function ContactForm() {
const [errors, setErrors] = useState({});
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const email = formData.get('email');
const message = formData.get('message');
const newErrors = {};
if (!email) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Please enter a valid email address';
}
if (!message) {
newErrors.message = 'Message is required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
// Focus on first error
const firstError = Object.keys(newErrors)[0];
document.getElementById(firstError)?.focus();
return;
}
setErrors({});
setSubmitted(true);
};
return (
<form onSubmit={handleSubmit} noValidate>
{submitted && (
<div role="alert" className="success-message">
Thank you! Your message has been sent.
</div>
)}
<div className="form-group">
<label htmlFor="email">
Email Address <span aria-label="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
aria-required="true"
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" className="error" role="alert">
{errors.email}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="message">
Message <span aria-label="required">*</span>
</label>
<textarea
id="message"
name="message"
rows="5"
aria-required="true"
aria-invalid={errors.message ? 'true' : 'false'}
aria-describedby={errors.message ? 'message-error' : undefined}
/>
{errors.message && (
<span id="message-error" className="error" role="alert">
{errors.message}
</span>
)}
</div>
<button type="submit">
Send Message
</button>
</form>
);
}
Image Accessibility with Next.js Image Component
The Next.js Image component requires alt text, making it easier to remember accessibility.
// app/components/ProductCard.jsx
import Image from 'next/image';
export default function ProductCard({ product }) {
return (
<article className="product-card">
<Image
src={product.image}
alt={`${product.name} - ${product.description}`}
width={400}
height={300}
priority={product.featured}
/>
<h3>{product.name}</h3>
<p>{product.description}</p>
<p aria-label={`Price: ${product.price} dollars`}>
${product.price}
</p>
</article>
);
}
For decorative images:
<Image
src="/decorative-pattern.png"
alt=""
width={200}
height={200}
aria-hidden="true"
/>
Modal Dialog Accessibility
Modals need proper focus trapping and keyboard support.
// app/components/Modal.jsx
'use client';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
export default function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement;
modalRef.current?.focus();
// Prevent background scrolling
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'unset';
previousFocus.current?.focus();
};
}
}, [isOpen]);
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
ref={modalRef}
className="modal-content"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="close-button"
>
×
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.body
);
}
Loading States and Live Regions
Inform screen reader users about dynamic content changes.
// app/components/SearchResults.jsx
'use client';
import { useState } from 'react';
export default function SearchResults() {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState('');
const handleSearch = async (searchQuery) => {
setLoading(true);
setQuery(searchQuery);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
setResults([
{ id: 1, title: 'Result 1' },
{ id: 2, title: 'Result 2' }
]);
setLoading(false);
};
return (
<div>
<input
type="search"
placeholder="Search..."
onChange={(e) => handleSearch(e.target.value)}
aria-label="Search products"
/>
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{loading && 'Loading search results...'}
{!loading && results.length > 0 &&
`Found ${results.length} results for ${query}`}
{!loading && results.length === 0 && query &&
'No results found'}
</div>
{loading && (
<div className="loading-spinner" aria-hidden="true">
Loading...
</div>
)}
{!loading && (
<ul aria-label="Search results">
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
)}
</div>
);
}
Color Contrast and Dark Mode
Ensure sufficient color contrast for readability.
// app/globals.css
:root {
--text-primary: #1a1a1a;
--text-secondary: #4a4a4a;
--background: #ffffff;
--border: #e0e0e0;
}
@media (prefers-color-scheme: dark) {
:root {
--text-primary: #f5f5f5;
--text-secondary: #d0d0d0;
--background: #1a1a1a;
--border: #404040;
}
}
/* Ensure at least 4.5:1 contrast ratio for normal text */
/* and 3:1 for large text */
// app/components/ThemeToggle.jsx
'use client';
import { useEffect, useState } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState('light');
useEffect(() => {
const stored = localStorage.getItem('theme');
const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
setTheme(stored || preferred);
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
};
return (
<button
onClick={toggleTheme}
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
aria-pressed={theme === 'dark'}
>
{theme === 'light' ? '🌙' : '☀️'}
<span className="sr-only">
Current theme: {theme}
</span>
</button>
);
}
Page Titles with Metadata API
Next.js 13+ uses the Metadata API for proper page titles.
// app/products/[id]/page.jsx
export async function generateMetadata({ params }) {
const product = await getProduct(params.id);
return {
title: `${product.name} - Our Store`,
description: product.description,
};
}
export default function ProductPage({ params }) {
return (
<div>
<h1>Product Details</h1>
{/* Content */}
</div>
);
}
Testing Accessibility
Install accessibility testing tools:
npm install -D @axe-core/react eslint-plugin-jsx-a11y
// next.config.js
module.exports = {
reactStrictMode: true,
eslint: {
dirs: ['app', 'components', 'lib'],
},
};
// .eslintrc.json
{
"extends": [
"next/core-web-vitals",
"plugin:jsx-a11y/recommended"
],
"plugins": ["jsx-a11y"]
}
For runtime testing during development:
// app/layout.jsx (only in development)
if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
});
}
Key Takeaways
- Use semantic HTML - Choose the right element for the job
- Provide text alternatives - Alt text for images, labels for inputs
- Ensure keyboard navigation - All interactive elements should be keyboard accessible
- Manage focus properly - Guide users through your interface
- Use sufficient color contrast - Maintain WCAG AA standards (4.5:1 for normal text)
- Provide feedback - Inform users about state changes and errors
- Test with real tools - Use screen readers and automated testing tools
- Think beyond compliance - Build experiences that work for everyone
Resources
- Next.js Accessibility Documentation
- Web Content Accessibility Guidelines (WCAG)
- MDN Web Accessibility
- A11y Project Checklist
By implementing these practices, you'll create Next.js applications that are usable by everyone, regardless of their abilities or the assistive technologies they use.
Top comments (0)