Command Palette

Search for a command to run...

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>&copy; 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>  );}

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>  );}

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>  );}

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>  );}

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>  );}

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>  );}

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  );}

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;}

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 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>  );}

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%;  }}

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;

WCAG 2.2 Core Principles

1. Perceivable
Information must be presentable to users in ways they can perceive

• 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

2. Operable
Interface components must be operable by all users

• 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

3. Understandable
Information and interface operation must be understandable

• Make text readable and understandable

• Make content appear and operate predictably

• Help users avoid and correct mistakes

• Provide clear instructions and error messages

4. Robust
Content must be robust enough for reliable interpretation

• Maximize compatibility with assistive technologies

• Ensure valid, semantic HTML

• Use ARIA attributes correctly

• Test with actual assistive technologies