AgentSkillsCN

accessibility-standards

为Next.js应用制定Web无障碍标准。涵盖WCAG 2.1 AA合规性、语义化HTML、ARIA模式、键盘导航、SPA中的焦点管理、屏幕阅读器测试、色彩对比度,以及Next.js特有的无障碍模式。

SKILL.md
--- frontmatter
name: accessibility-standards
description: "Web accessibility standards for Next.js applications. Covers WCAG 2.1 AA compliance, semantic HTML, ARIA patterns, keyboard navigation, focus management in SPAs, screen reader testing, color contrast, and Next.js-specific accessibility patterns."
license: MIT
metadata:
  author: Balazs Barta
  version: "0.1.0"

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

typescript
// 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

typescript
// 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>&copy; 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
'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

typescript
// 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

typescript
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

typescript
'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

typescript
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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
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

typescript
// 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

typescript
<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

typescript
// 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

typescript
// 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

typescript
// 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

  1. NVDA (Windows): Free, open-source screen reader
  2. JAWS (Windows): Industry standard, paid
  3. VoiceOver (macOS): Built-in, activate with Cmd+F5
  4. TalkBack (Android): Built-in screen reader
  5. 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

bash
npm install -D eslint-plugin-jsx-a11y

Configuration

javascript
// .eslintrc.json
{
  "extends": ["plugin:jsx-a11y/recommended"],
  "plugins": ["jsx-a11y"]
}

axe-core for Testing

bash
npm install -D @axe-core/react
typescript
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