Accessibility
Build inclusive Next.js applications that work for everyone, following WCAG 2.2 guidelines.
Web accessibility ensures that your application can be used by everyone, including people with disabilities. This guide provides practical examples following WCAG 2.2 standards and Next.js best practices.
Use semantic HTML elements to provide meaning and structure. This helps screen readers understand your content hierarchy.
export default function Layout({ children }) { return ( <div> <header> <nav aria-label="Main navigation"> <ul> <li><a href="/">Home</a></li> <li><a href="/about">About</a></li> <li><a href="/contact">Contact</a></li> </ul> </nav> </header> <main id="main-content"> {children} </main> <footer> <p>© 2024 Your Company. All rights reserved.</p> </footer> </div> );}export default function Layout({ children }) { return ( <div> <header> <nav aria-label="Main navigation"> <ul> <li><a href="/">Home</a></li> <li><a href="/about">About</a></li> <li><a href="/contact">Contact</a></li> </ul> </nav> </header> <main id="main-content"> {children} </main> <footer> <p>© 2024 Your Company. All rights reserved.</p> </footer> </div> );}Skip links allow keyboard users to bypass repetitive content and jump directly to the main content. Essential for WCAG 2.4.1 compliance.
export function SkipLink() { return ( <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-md" > Skip to main content </a> );}// In your layoutexport default function RootLayout({ children }) { return ( <html lang="en"> <body> <SkipLink /> <Navigation /> <main id="main-content" tabIndex={-1}> {children} </main> </body> </html> );}export function SkipLink() { return ( <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-md" > Skip to main content </a> );}// In your layoutexport default function RootLayout({ children }) { return ( <html lang="en"> <body> <SkipLink /> <Navigation /> <main id="main-content" tabIndex={-1}> {children} </main> </body> </html> );}Buttons must be keyboard accessible, have proper labels, and visible focus states. Always use proper ARIA attributes when needed.
"use client";export function Button({ children, onClick, disabled = false, ariaLabel, ariaDescribedBy, type = "button"}) { return ( <button type={type} onClick={onClick} disabled={disabled} aria-label={ariaLabel} aria-describedby={ariaDescribedBy} className="px-4 py-2 bg-primary text-primary-foreground rounded-md disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" > {children} </button> );}// Icon button exampleexport function IconButton({ icon: Icon, label, onClick }) { return ( <button onClick={onClick} aria-label={label} className="p-2 rounded-md hover:bg-accent focus:outline-none focus:ring-2 focus:ring-primary" > <Icon className="w-5 h-5" aria-hidden="true" /> </button> );}"use client";export function Button({ children, onClick, disabled = false, ariaLabel, ariaDescribedBy, type = "button"}) { return ( <button type={type} onClick={onClick} disabled={disabled} aria-label={ariaLabel} aria-describedby={ariaDescribedBy} className="px-4 py-2 bg-primary text-primary-foreground rounded-md disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" > {children} </button> );}// Icon button exampleexport function IconButton({ icon: Icon, label, onClick }) { return ( <button onClick={onClick} aria-label={label} className="p-2 rounded-md hover:bg-accent focus:outline-none focus:ring-2 focus:ring-primary" > <Icon className="w-5 h-5" aria-hidden="true" /> </button> );}Forms must have proper labels, error messages, and validation states that are accessible to screen readers.
"use client";import { useState } from "react";export function AccessibleForm() { const [email, setEmail] = useState(""); const [error, setError] = useState(""); const handleSubmit = (e) => { e.preventDefault(); if (!email.includes("@")) { setError("Please enter a valid email address"); return; } // Submit form }; return ( <form onSubmit={handleSubmit} noValidate> <div> <label htmlFor="email" className="block mb-2"> Email Address <span aria-label="required">*</span> </label> <input type="email" id="email" name="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-required="true" aria-invalid={error ? "true" : "false"} aria-describedby={error ? "email-error" : undefined} className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" /> {error && ( <p id="email-error" className="mt-1 text-sm text-destructive" role="alert"> {error} </p> )} </div> <button type="submit" className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md" > Submit </button> </form> );}"use client";import { useState } from "react";export function AccessibleForm() { const [email, setEmail] = useState(""); const [error, setError] = useState(""); const handleSubmit = (e) => { e.preventDefault(); if (!email.includes("@")) { setError("Please enter a valid email address"); return; } // Submit form }; return ( <form onSubmit={handleSubmit} noValidate> <div> <label htmlFor="email" className="block mb-2"> Email Address <span aria-label="required">*</span> </label> <input type="email" id="email" name="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-required="true" aria-invalid={error ? "true" : "false"} aria-describedby={error ? "email-error" : undefined} className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" /> {error && ( <p id="email-error" className="mt-1 text-sm text-destructive" role="alert"> {error} </p> )} </div> <button type="submit" className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md" > Submit </button> </form> );}All images must have appropriate alt text. Use descriptive text for informative images, empty alt for decorative images, and detailed descriptions for complex images.
import Image from "next/image";// Informative image - needs descriptive alt textexport function ProductImage() { return ( <Image src="/product.jpg" alt="Blue cotton t-shirt with round neck and short sleeves" width={400} height={400} /> );}// Decorative image - use empty altexport function DecorativeImage() { return ( <Image src="/decoration.jpg" alt="" width={400} height={400} aria-hidden="true" /> );}// Complex image with descriptionexport function ChartImage() { return ( <figure> <Image src="/sales-chart.jpg" alt="Sales growth chart" width={800} height={400} aria-describedby="chart-description" /> <figcaption id="chart-description"> A line chart showing 25% sales growth from Q1 to Q4 2024 </figcaption> </figure> );}import Image from "next/image";// Informative image - needs descriptive alt textexport function ProductImage() { return ( <Image src="/product.jpg" alt="Blue cotton t-shirt with round neck and short sleeves" width={400} height={400} /> );}// Decorative image - use empty altexport function DecorativeImage() { return ( <Image src="/decoration.jpg" alt="" width={400} height={400} aria-hidden="true" /> );}// Complex image with descriptionexport function ChartImage() { return ( <figure> <Image src="/sales-chart.jpg" alt="Sales growth chart" width={800} height={400} aria-describedby="chart-description" /> <figcaption id="chart-description"> A line chart showing 25% sales growth from Q1 to Q4 2024 </figcaption> </figure> );}Use live regions to announce dynamic content changes to screen reader users without interrupting their current task.
"use client";import { useState } from "react";export function NotificationSystem() { const [message, setMessage] = useState(""); const notify = (text) => { setMessage(text); setTimeout(() => setMessage(""), 5000); }; return ( <div> <button onClick={() => notify("Item added to cart")}> Add to Cart </button> {/* Live region for screen readers */} <div role="status" aria-live="polite" aria-atomic="true" className="sr-only" > {message} </div> {/* Visual notification */} {message && ( <div className="fixed bottom-4 right-4 p-4 bg-primary text-primary-foreground rounded-md"> {message} </div> )} </div> );}"use client";import { useState } from "react";export function NotificationSystem() { const [message, setMessage] = useState(""); const notify = (text) => { setMessage(text); setTimeout(() => setMessage(""), 5000); }; return ( <div> <button onClick={() => notify("Item added to cart")}> Add to Cart </button> {/* Live region for screen readers */} <div role="status" aria-live="polite" aria-atomic="true" className="sr-only" > {message} </div> {/* Visual notification */} {message && ( <div className="fixed bottom-4 right-4 p-4 bg-primary text-primary-foreground rounded-md"> {message} </div> )} </div> );}All functionality must be accessible via keyboard. Implement proper arrow key navigation, Home/End keys, and Escape key support for menus and dropdowns.
"use client";import { useState, useRef, useEffect } from "react";export function KeyboardMenu() { const [isOpen, setIsOpen] = useState(false); const [focusedIndex, setFocusedIndex] = useState(0); const menuRef = useRef(null); const menuItems = [ { label: "Profile", href: "/profile" }, { label: "Settings", href: "/settings" }, { label: "Logout", href: "/logout" }, ]; const handleKeyDown = (e) => { switch (e.key) { case "ArrowDown": e.preventDefault(); setFocusedIndex((prev) => prev < menuItems.length - 1 ? prev + 1 : 0 ); break; case "ArrowUp": e.preventDefault(); setFocusedIndex((prev) => prev > 0 ? prev - 1 : menuItems.length - 1 ); break; case "Escape": setIsOpen(false); break; case "Home": e.preventDefault(); setFocusedIndex(0); break; case "End": e.preventDefault(); setFocusedIndex(menuItems.length - 1); break; } }; return ( <div> <button onClick={() => setIsOpen(!isOpen)} aria-expanded={isOpen} aria-haspopup="true" aria-controls="menu" > Menu </button> {isOpen && ( <ul id="menu" role="menu" ref={menuRef} onKeyDown={handleKeyDown} className="mt-2 border rounded-md" > {menuItems.map((item, index) => ( <li key={item.href} role="none"> <a href={item.href} role="menuitem" tabIndex={focusedIndex === index ? 0 : -1} className="block px-4 py-2 hover:bg-accent" > {item.label} </a> </li> ))} </ul> )} </div> );}"use client";import { useState, useRef, useEffect } from "react";export function KeyboardMenu() { const [isOpen, setIsOpen] = useState(false); const [focusedIndex, setFocusedIndex] = useState(0); const menuRef = useRef(null); const menuItems = [ { label: "Profile", href: "/profile" }, { label: "Settings", href: "/settings" }, { label: "Logout", href: "/logout" }, ]; const handleKeyDown = (e) => { switch (e.key) { case "ArrowDown": e.preventDefault(); setFocusedIndex((prev) => prev < menuItems.length - 1 ? prev + 1 : 0 ); break; case "ArrowUp": e.preventDefault(); setFocusedIndex((prev) => prev > 0 ? prev - 1 : menuItems.length - 1 ); break; case "Escape": setIsOpen(false); break; case "Home": e.preventDefault(); setFocusedIndex(0); break; case "End": e.preventDefault(); setFocusedIndex(menuItems.length - 1); break; } }; return ( <div> <button onClick={() => setIsOpen(!isOpen)} aria-expanded={isOpen} aria-haspopup="true" aria-controls="menu" > Menu </button> {isOpen && ( <ul id="menu" role="menu" ref={menuRef} onKeyDown={handleKeyDown} className="mt-2 border rounded-md" > {menuItems.map((item, index) => ( <li key={item.href} role="none"> <a href={item.href} role="menuitem" tabIndex={focusedIndex === index ? 0 : -1} className="block px-4 py-2 hover:bg-accent" > {item.label} </a> </li> ))} </ul> )} </div> );}Manage focus properly when opening/closing modals, dialogs, and other dynamic content. Trap focus within modals and restore it when closed.
"use client";import { useEffect, useRef } from "react";import { createPortal } from "react-dom";export function AccessibleModal({ isOpen, onClose, title, children }) { const modalRef = useRef(null); const previousFocusRef = useRef(null); useEffect(() => { if (isOpen) { // Store the currently focused element previousFocusRef.current = document.activeElement; // Focus the modal modalRef.current?.focus(); // Trap focus within modal const handleTabKey = (e) => { const focusableElements = modalRef.current?.querySelectorAll( 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements?.[0]; const lastElement = focusableElements?.[focusableElements.length - 1]; if (e.key === "Tab") { if (e.shiftKey && document.activeElement === firstElement) { e.preventDefault(); lastElement?.focus(); } else if (!e.shiftKey && document.activeElement === lastElement) { e.preventDefault(); firstElement?.focus(); } } }; document.addEventListener("keydown", handleTabKey); return () => { document.removeEventListener("keydown", handleTabKey); // Restore focus when modal closes previousFocusRef.current?.focus(); }; } }, [isOpen]); if (!isOpen) return null; return createPortal( <div className="fixed inset-0 bg-black/50 flex items-center justify-center" onClick={onClose} role="dialog" aria-modal="true" aria-labelledby="modal-title" > <div ref={modalRef} className="bg-background p-6 rounded-lg max-w-md w-full" onClick={(e) => e.stopPropagation()} tabIndex={-1} > <h2 id="modal-title" className="text-xl font-bold mb-4"> {title} </h2> {children} <button onClick={onClose} className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md" > Close </button> </div> </div>, document.body );}"use client";import { useEffect, useRef } from "react";import { createPortal } from "react-dom";export function AccessibleModal({ isOpen, onClose, title, children }) { const modalRef = useRef(null); const previousFocusRef = useRef(null); useEffect(() => { if (isOpen) { // Store the currently focused element previousFocusRef.current = document.activeElement; // Focus the modal modalRef.current?.focus(); // Trap focus within modal const handleTabKey = (e) => { const focusableElements = modalRef.current?.querySelectorAll( 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements?.[0]; const lastElement = focusableElements?.[focusableElements.length - 1]; if (e.key === "Tab") { if (e.shiftKey && document.activeElement === firstElement) { e.preventDefault(); lastElement?.focus(); } else if (!e.shiftKey && document.activeElement === lastElement) { e.preventDefault(); firstElement?.focus(); } } }; document.addEventListener("keydown", handleTabKey); return () => { document.removeEventListener("keydown", handleTabKey); // Restore focus when modal closes previousFocusRef.current?.focus(); }; } }, [isOpen]); if (!isOpen) return null; return createPortal( <div className="fixed inset-0 bg-black/50 flex items-center justify-center" onClick={onClose} role="dialog" aria-modal="true" aria-labelledby="modal-title" > <div ref={modalRef} className="bg-background p-6 rounded-lg max-w-md w-full" onClick={(e) => e.stopPropagation()} tabIndex={-1} > <h2 id="modal-title" className="text-xl font-bold mb-4"> {title} </h2> {children} <button onClick={onClose} className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md" > Close </button> </div> </div>, document.body );}Ensure sufficient color contrast between text and background. WCAG 2.2 Level AA requires 4.5:1 for normal text and 3:1 for large text.
/* WCAG 2.2 Level AA requires: * - 4.5:1 for normal text (under 24px or 19px bold) * - 3:1 for large text (24px+ or 19px+ bold) * - 3:1 for UI components and graphics */:root { /* Good contrast examples */ --primary: 220 13% 13%; /* Dark blue - passes AA */ --primary-foreground: 0 0% 100%; /* White - passes AAA */ --secondary: 220 13% 91%; /* Light blue - passes AA */ --secondary-foreground: 220 13% 13%; /* Dark blue - passes AAA */ /* Focus indicators must be visible */ --ring: 220 13% 50%; --ring-offset: 0 0% 100%;}/* Ensure focus is always visible */:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px;}/* Don't remove focus indicators */*:focus:not(:focus-visible) { outline: none;}/* WCAG 2.2 Level AA requires: * - 4.5:1 for normal text (under 24px or 19px bold) * - 3:1 for large text (24px+ or 19px+ bold) * - 3:1 for UI components and graphics */:root { /* Good contrast examples */ --primary: 220 13% 13%; /* Dark blue - passes AA */ --primary-foreground: 0 0% 100%; /* White - passes AAA */ --secondary: 220 13% 91%; /* Light blue - passes AA */ --secondary-foreground: 220 13% 13%; /* Dark blue - passes AAA */ /* Focus indicators must be visible */ --ring: 220 13% 50%; --ring-offset: 0 0% 100%;}/* Ensure focus is always visible */:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px;}/* Don't remove focus indicators */*:focus:not(:focus-visible) { outline: none;}Ensure your application works on all screen sizes and orientations. Mobile navigation should be just as accessible as desktop.
"use client";import { useState } from "react";import { Menu, X } from "lucide-react";export function ResponsiveNav() { const [isOpen, setIsOpen] = useState(false); return ( <nav aria-label="Main navigation"> {/* Mobile menu button */} <button onClick={() => setIsOpen(!isOpen)} aria-expanded={isOpen} aria-controls="mobile-menu" className="md:hidden" aria-label={isOpen ? "Close menu" : "Open menu"} > {isOpen ? ( <X aria-hidden="true" /> ) : ( <Menu aria-hidden="true" /> )} </button> {/* Desktop navigation */} <ul className="hidden md:flex gap-4"> <li><a href="/">Home</a></li> <li><a href="/about">About</a></li> <li><a href="/contact">Contact</a></li> </ul> {/* Mobile navigation */} {isOpen && ( <ul id="mobile-menu" className="md:hidden"> <li><a href="/">Home</a></li> <li><a href="/about">About</a></li> <li><a href="/contact">Contact</a></li> </ul> )} </nav> );}"use client";import { useState } from "react";import { Menu, X } from "lucide-react";export function ResponsiveNav() { const [isOpen, setIsOpen] = useState(false); return ( <nav aria-label="Main navigation"> {/* Mobile menu button */} <button onClick={() => setIsOpen(!isOpen)} aria-expanded={isOpen} aria-controls="mobile-menu" className="md:hidden" aria-label={isOpen ? "Close menu" : "Open menu"} > {isOpen ? ( <X aria-hidden="true" /> ) : ( <Menu aria-hidden="true" /> )} </button> {/* Desktop navigation */} <ul className="hidden md:flex gap-4"> <li><a href="/">Home</a></li> <li><a href="/about">About</a></li> <li><a href="/contact">Contact</a></li> </ul> {/* Mobile navigation */} {isOpen && ( <ul id="mobile-menu" className="md:hidden"> <li><a href="/">Home</a></li> <li><a href="/about">About</a></li> <li><a href="/contact">Contact</a></li> </ul> )} </nav> );}Use proper table markup with caption, thead, tbody, and scope attributes to help screen readers understand table relationships.
export function AccessibleTable({ data }) { return ( <table> <caption className="text-lg font-bold mb-2"> Sales Data for Q4 2024 </caption> <thead> <tr> <th scope="col">Month</th> <th scope="col">Revenue</th> <th scope="col">Growth</th> </tr> </thead> <tbody> {data.map((row) => ( <tr key={row.month}> <th scope="row">{row.month}</th> <td>{row.revenue}</td> <td>{row.growth}</td> </tr> ))} </tbody> </table> );}export function AccessibleTable({ data }) { return ( <table> <caption className="text-lg font-bold mb-2"> Sales Data for Q4 2024 </caption> <thead> <tr> <th scope="col">Month</th> <th scope="col">Revenue</th> <th scope="col">Growth</th> </tr> </thead> <tbody> {data.map((row) => ( <tr key={row.month}> <th scope="row">{row.month}</th> <td>{row.revenue}</td> <td>{row.growth}</td> </tr> ))} </tbody> </table> );}Respect user motion preferences using prefers-reduced-motion. Some users experience vestibular disorders triggered by motion animations.
/* Respect user's motion preferences */@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }}/* Smooth scroll for users who don't prefer reduced motion */@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; }}/* High contrast mode support */@media (prefers-contrast: high) { :root { --primary: 0 0% 0%; --primary-foreground: 0 0% 100%; --border: 0 0% 0%; }}/* Respect user's motion preferences */@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }}/* Smooth scroll for users who don't prefer reduced motion */@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; }}/* High contrast mode support */@media (prefers-contrast: high) { :root { --primary: 0 0% 0%; --primary-foreground: 0 0% 100%; --border: 0 0% 0%; }}Configure Next.js for better accessibility with React Strict Mode, internationalization, and optimized images.
/** @type {import('next').NextConfig} */const nextConfig = { // Enable React strict mode for better accessibility warnings reactStrictMode: true, // Configure i18n for language support i18n: { locales: ['en', 'es', 'fr'], defaultLocale: 'en', }, // Image optimization maintains aspect ratios and supports alt text images: { domains: ['yourdomain.com'], formats: ['image/avif', 'image/webp'], },};module.exports = nextConfig;/** @type {import('next').NextConfig} */const nextConfig = { // Enable React strict mode for better accessibility warnings reactStrictMode: true, // Configure i18n for language support i18n: { locales: ['en', 'es', 'fr'], defaultLocale: 'en', }, // Image optimization maintains aspect ratios and supports alt text images: { domains: ['yourdomain.com'], formats: ['image/avif', 'image/webp'], },};module.exports = nextConfig;WCAG 2.2 Core Principles
• Provide text alternatives for non-text content
• Provide captions and alternatives for multimedia
• Create content that can be presented in different ways
• Make it easier to see and hear content
• Make all functionality available from keyboard
• Give users enough time to read and use content
• Don't use content that causes seizures
• Help users navigate and find content
• Make text readable and understandable
• Make content appear and operate predictably
• Help users avoid and correct mistakes
• Provide clear instructions and error messages
• Maximize compatibility with assistive technologies
• Ensure valid, semantic HTML
• Use ARIA attributes correctly
• Test with actual assistive technologies