All guides
Engineering13 min read

Web Accessibility Guide for Next.js Developers

Building accessible Next.js applications is both a legal requirement and good product design. This guide covers the patterns, tools, and testing approaches that make accessibility achievable.

NC

Nextcraft Agency

Why Accessibility Is a Technical Concern

Accessibility is often framed as a design or compliance issue. It's primarily a technical one. The patterns that make a site accessible — correct semantic HTML, keyboard navigation, ARIA attributes, focus management — are all implemented in code.

The business case is real:

  • In the US, the ADA requires web accessibility for businesses open to the public. WCAG 2.1 AA compliance is the legal benchmark.
  • 15–20% of the world's population has some form of disability. Poor accessibility excludes them from your product.
  • Accessibility improvements frequently improve usability for everyone — better keyboard navigation, clearer error messages, and higher contrast benefit all users.

Part 1: Semantic HTML

The foundation of accessibility is semantic HTML. Most accessibility problems come from using generic elements (<div>, <span>) where semantic elements exist.

Headings

Headings communicate page structure to screen readers. Rules:

  • One <h1> per page — it's the page title
  • Don't skip levels (h1 → h3 without h2)
  • Don't use headings for styling — use them for document structure
code
// Bad — heading for styling
<h4 className="text-sm font-bold text-gray-500">CATEGORIES</h4>

// Good — semantic: it's a label, not a heading
<p className="text-sm font-bold text-gray-500 uppercase">Categories</p>

Buttons vs Links

The most common semantic mistake:

  • <button> — triggers an action (submit, open modal, toggle, delete)
  • <a href> — navigates to a URL

Using a <div onClick> or <a> without an href for actions, or a <button> for navigation, breaks keyboard navigation and screen reader announcements.

code
// Bad — div acting as button
<div onClick={handleDelete} className="cursor-pointer text-red-500">
  Delete
</div>

// Bad — link with no href acting as button
<a onClick={openModal} className="cursor-pointer">Open Modal</a>

// Good — semantic button
<button onClick={handleDelete} className="text-red-500">
  Delete
</button>

// Good — link for navigation
<Link href="/dashboard">Go to Dashboard</Link>

Form Labels

Every form field must have a label. Screen readers announce the label when the field receives focus.

code
// Bad — placeholder is not a label
<input placeholder="Email address" />

// Good — explicit label
<label htmlFor="email">Email address</label>
<input id="email" type="email" />

// Good — accessible label without visible label element
<input
  aria-label="Search products"
  type="search"
  placeholder="Search..."
/>

Lists

Use <ul>, <ol>, and <li> for lists. Screen readers announce "list of N items" and let users navigate between items efficiently.

code
// Bad — fake list
<div>
  <div>Item 1</div>
  <div>Item 2</div>
  <div>Item 3</div>
</div>

// Good — semantic list
<ul>
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
</ul>

Part 2: Keyboard Navigation

Every interactive element must be reachable and operable via keyboard. Tab moves between interactive elements; Enter/Space activates buttons and links.

Focus Visibility

The focus ring must be visible. Removing it breaks keyboard navigation for all keyboard users:

code
/* Never do this globally */
* { outline: none; }

Use Tailwind's focus-visible classes to show focus rings only for keyboard navigation (not mouse clicks):

code
<button className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2">
  Click me
</button>

focus-visible applies only when focus came from keyboard navigation, not mouse click. This satisfies both sighted keyboard users and mouse users.

Tab Order

The tab order should follow the visual reading order. Avoid tabIndex values above 0 — they create confusing tab orders that don't match the visual layout.

code
// Bad — creates artificial tab order
<button tabIndex={3}>Third</button>
<button tabIndex={1}>First</button>
<button tabIndex={2}>Second</button>

// Good — natural order; adjust DOM order if needed
<button>First</button>
<button>Second</button>
<button>Third</button>

Use tabIndex={-1} to make elements programmatically focusable (for focus management) without adding them to the tab order.

Modal Focus Management

When a modal opens, focus should move into it. When it closes, focus should return to the trigger element.

code
'use client';

import { useEffect, useRef } from 'react';

function Modal({ isOpen, onClose, triggerRef, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (isOpen) {
      // Move focus into modal
      modalRef.current?.focus();
    } else {
      // Return focus to trigger
      triggerRef.current?.focus();
    }
  }, [isOpen, triggerRef]);

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      tabIndex={-1}
      className="..."
    >
      <h2 id="modal-title">Modal Title</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

Also trap focus within the modal — Tab should cycle through modal elements only, not the page behind.


Part 3: ARIA

ARIA (Accessible Rich Internet Applications) attributes communicate to assistive technology what native HTML semantics can't. Use ARIA only when native semantics are insufficient.

The ARIA Rules

Rule 1: Use native HTML elements when they exist. <button> over <div role="button">.

Rule 2: Don't add ARIA to elements that already have the semantics. <button role="button"> is redundant.

Rule 3: ARIA attributes must accurately reflect the current state.

Common ARIA Patterns

Labeling elements:

code
// For elements where a visual label isn't present
<button aria-label="Close dialog">
  <XIcon />
</button>

// For elements where the label is elsewhere in the DOM
<nav aria-labelledby="nav-heading">
  <h2 id="nav-heading">Main Navigation</h2>
  ...
</nav>

Live regions for dynamic content:

code
// Announces content changes to screen readers
<div
  aria-live="polite"          // Don't interrupt current announcement
  aria-atomic="true"           // Announce the full region, not just changes
>
  {notification && <p>{notification.message}</p>}
</div>

// For critical alerts, use assertive (interrupts)
<div role="alert" aria-live="assertive">
  {error && <p>{error.message}</p>}
</div>

Loading states:

code
<button
  aria-busy={isLoading}
  disabled={isLoading}
>
  {isLoading ? 'Saving...' : 'Save'}
</button>

Expanded/collapsed state:

code
<button
  aria-expanded={isOpen}
  aria-controls="dropdown-menu"
>
  Menu
</button>
<ul
  id="dropdown-menu"
  hidden={!isOpen}
>
  ...
</ul>

Part 4: Color and Contrast

Contrast Ratios

WCAG 2.1 AA requires:

  • Normal text (under 18pt/14pt bold): 4.5:1 contrast ratio
  • Large text (18pt+ / 14pt+ bold): 3:1 contrast ratio
  • UI components and graphics: 3:1 contrast ratio

Check your color choices with a contrast checker. Tailwind's default color palette — gray-600 on white background — passes AA for normal text. Custom brand colors often fail.

Don't Use Color Alone

Color should never be the only way to communicate information:

code
// Bad — users with color blindness can't distinguish required vs optional
<label>
  Email <span className="text-red-500">*</span>
</label>

// Good — text reinforces the color indicator
<label>
  Email <span className="text-red-500" aria-label="required">* required</span>
</label>

Error States

Form error messages must be associated with their fields:

code
<div>
  <label htmlFor="email">Email</label>
  <input
    id="email"
    type="email"
    aria-describedby={error ? 'email-error' : undefined}
    aria-invalid={!!error}
    className={cn('border rounded', error && 'border-red-500')}
  />
  {error && (
    <p id="email-error" className="text-red-500 text-sm mt-1">
      {error}
    </p>
  )}
</div>

aria-describedby links the field to its error message. aria-invalid="true" tells screen readers the field has an error.


Part 5: Next.js Specific Patterns

Skip Navigation Link

Add a "skip to main content" link as the first element in the DOM:

code
// app/layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <a
          href="#main-content"
          className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 bg-white px-4 py-2 rounded"
        >
          Skip to main content
        </a>
        <Header />
        <main id="main-content" tabIndex={-1}>
          {children}
        </main>
      </body>
    </html>
  );
}

This lets keyboard users skip the navigation on every page. The sr-only class hides it visually; focus:not-sr-only reveals it on Tab.

Image Alt Text in Next.js

code
import Image from 'next/image';

// Informative image — describe what's shown
<Image src="/chart.png" alt="Bar chart showing monthly revenue growing 40% from January to December 2025" />

// Decorative image — empty alt
<Image src="/decoration.svg" alt="" />

// Image with caption — alt can be brief, caption provides detail
<figure>
  <Image src="/case-study.jpg" alt="Estate Pro dashboard" />
  <figcaption>The Estate Pro property management dashboard after our redesign</figcaption>
</figure>

Part 6: Testing Accessibility

Automated Testing

Catch the ~30% of issues that automated tools can find:

code
// Using axe-core in Jest tests
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';

expect.extend(toHaveNoViolations);

test('ContactForm has no accessibility violations', async () => {
  const { container } = render(<ContactForm />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Manual Testing Checklist

Automated tools miss ~70% of accessibility issues. Manual testing is required:

  • Tab through the entire page — can you reach every interactive element?
  • Every interactive element is operable with Enter or Space
  • Focus ring is visible on all interactive elements
  • Screen reader announces meaningful content for all elements (use NVDA on Windows, VoiceOver on Mac)
  • Page works with browser zoom at 200%
  • Color contrast passes on all text
  • Forms work correctly with screen reader — labels, errors, required fields all announced
  • Videos have captions, audio has transcripts

Browser Extensions

  • axe DevTools: Chrome/Firefox extension for automated accessibility checking on any page
  • Accessibility Insights: Microsoft's accessibility testing extension with guided manual testing workflows
  • WAVE: Visual overlay showing accessibility issues on the rendered page

Build accessibility testing into your PR review process. Like security, it's easier to maintain than it is to retrofit.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.