frontend-a11y

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Frontend Accessibility Patterns

前端可访问性模式

Practical accessibility patterns for React and Next.js. Covers the issues most commonly flagged in code review: missing form labels, incorrect ARIA usage, non-semantic interactive elements, and broken keyboard navigation.
适用于React与Next.js的实用可访问性模式。涵盖代码审查中最常标记的问题:缺失表单标签、ARIA使用不当、非语义化交互元素以及键盘导航失效。

When to Activate

适用场景

  • Building or reviewing form components (
    <input>
    ,
    <select>
    ,
    <textarea>
    )
  • Creating interactive elements (modals, dropdowns, tooltips, tabs)
  • Using
    <div>
    or
    <span>
    with
    onClick
  • Adding
    aria-*
    attributes to any element
  • Implementing keyboard navigation or focus management
  • Receiving accessibility feedback from code review tools (CodeRabbit, ESLint a11y)
  • Building components that must support screen readers
  • 构建或审查表单组件(
    <input>
    <select>
    <textarea>
  • 创建交互元素(模态框、下拉菜单、提示框、标签页)
  • <div>
    <span>
    添加
    onClick
    事件
  • 为任何元素添加
    aria-*
    属性
  • 实现键盘导航或焦点管理
  • 收到代码审查工具(CodeRabbit、ESLint a11y)的可访问性反馈
  • 构建必须支持屏幕阅读器的组件

Form Accessibility

表单可访问性

Missing
htmlFor
/
id
pairing and disconnected error messages are the most common issues flagged in code review.
缺失
htmlFor
/
id
配对以及错误消息未关联是代码审查中最常见的问题。

Label Connection

标签关联

tsx
// BAD: label has no connection to input — screen readers cannot associate them
<label>Email</label>
<input type="email" />

// GOOD: htmlFor matches input id
<label htmlFor="email">Email</label>
<input id="email" type="email" />
tsx
// 错误示例:标签与输入框无关联——屏幕阅读器无法识别它们的关系
<label>Email</label>
<input type="email" />

// 正确示例:htmlFor与输入框id匹配
<label htmlFor="email">Email</label>
<input id="email" type="email" />

Required Fields

必填字段

tsx
// BAD: visual-only asterisk conveys nothing to screen readers
<label htmlFor="email">Email *</label>
<input id="email" type="email" />

// GOOD: required enables native browser validation; aria-required signals it to screen readers
<label htmlFor="email">
  Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
tsx
// 错误示例:仅视觉上的星号对屏幕阅读器无意义
<label htmlFor="email">Email *</label>
<input id="email" type="email" />

// 正确示例:required启用原生浏览器验证;aria-required向屏幕阅读器传达必填信息
<label htmlFor="email">
  Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />

Error Messages

错误消息

tsx
// BAD: error text exists visually but is not linked to the input
<input id="email" type="email" />
<span className="error">Invalid email address</span>

// GOOD: aria-describedby connects input to its error message
// aria-invalid signals the invalid state to screen readers
<input
  id="email"
  type="email"
  aria-describedby="email-error"
  aria-invalid={!!error}
/>
{error && (
  <span id="email-error" role="alert">
    {error}
  </span>
)}
tsx
// 错误示例:错误文本仅在视觉上显示,但未与输入框关联
<input id="email" type="email" />
<span className="error">Invalid email address</span>

// 正确示例:aria-describedby将输入框与其错误消息关联
// aria-invalid向屏幕阅读器标记无效状态
<input
  id="email"
  type="email"
  aria-describedby="email-error"
  aria-invalid={!!error}
/>
{error && (
  <span id="email-error" role="alert">
    {error}
  </span>
)}

Complete Accessible Form

完整的可访问表单

tsx
interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const newErrors: typeof errors = {};
    if (!email) newErrors.email = 'Email is required';
    if (!password) newErrors.password = 'Password is required';
    if (Object.keys(newErrors).length) {
      setErrors(newErrors);
      return;
    }
    onSubmit(email, password);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">
          Email <span aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          aria-required="true"
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={!!errors.email}
          autoComplete="email"
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">
          Password <span aria-hidden="true">*</span>
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          aria-required="true"
          aria-describedby={errors.password ? 'password-error' : undefined}
          aria-invalid={!!errors.password}
          autoComplete="current-password"
        />
        {errors.password && (
          <span id="password-error" role="alert">
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">Log in</button>
    </form>
  );
}
tsx
interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const newErrors: typeof errors = {};
    if (!email) newErrors.email = 'Email is required';
    if (!password) newErrors.password = 'Password is required';
    if (Object.keys(newErrors).length) {
      setErrors(newErrors);
      return;
    }
    onSubmit(email, password);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">
          Email <span aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          aria-required="true"
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={!!errors.email}
          autoComplete="email"
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">
          Password <span aria-hidden="true">*</span>
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          aria-required="true"
          aria-describedby={errors.password ? 'password-error' : undefined}
          aria-invalid={!!errors.password}
          autoComplete="current-password"
        />
        {errors.password && (
          <span id="password-error" role="alert">
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">Log in</button>
    </form>
  );
}

Semantic HTML

语义化HTML

Use the element that matches the intent. Screen readers and keyboard users depend on native semantics.
tsx
// BAD: div has no role, no keyboard support, no accessible name
<div onClick={handleClick}>Submit</div>

// GOOD: button is focusable, activates on Enter/Space, announces as "button"
<button type="button" onClick={handleClick}>Submit</button>
tsx
// BAD: non-semantic navigation
<div onClick={() => navigate('/home')}>Home</div>

// GOOD: anchor supports right-click, middle-click, and keyboard navigation
<a href="/home">Home</a>
tsx
// BAD: heading hierarchy skipped (h1 to h4)
<h1>Dashboard</h1>
<h4>Recent Activity</h4>

// GOOD: sequential heading levels
<h1>Dashboard</h1>
<h2>Recent Activity</h2>
使用符合用途的元素。屏幕阅读器和键盘用户依赖原生语义。
tsx
// 错误示例:div无角色、无键盘支持、无可访问名称
<div onClick={handleClick}>Submit</div>

// 正确示例:button可聚焦,按Enter/Space触发,被屏幕阅读器识别为“button”
<button type="button" onClick={handleClick}>Submit</button>
tsx
// 错误示例:非语义化导航
<div onClick={() => navigate('/home')}>Home</div>

// 正确示例:锚点支持右键、中键点击以及键盘导航
<a href="/home">Home</a>
tsx
// 错误示例:标题层级跳跃(h1到h4)
<h1>Dashboard</h1>
<h4>Recent Activity</h4>

// 正确示例:连续的标题层级
<h1>Dashboard</h1>
<h2>Recent Activity</h2>

ARIA Attributes

ARIA属性

Use ARIA only when native HTML semantics are insufficient. Wrong ARIA is worse than no ARIA.
仅当原生HTML语义不足时才使用ARIA。错误的ARIA比不使用ARIA更糟。

aria-label vs aria-labelledby

aria-label vs aria-labelledby

tsx
// aria-label: inline string label — use when no visible label text exists
<button aria-label="Close modal">
  <XIcon />
</button>

// aria-labelledby: references another element's text — use when a visible label exists
<section aria-labelledby="section-title">
  <h2 id="section-title">Recent Orders</h2>
  {/* content */}
</section>
tsx
// aria-label:内联字符串标签——适用于无可见标签文本的场景
<button aria-label="Close modal">
  <XIcon />
</button>

// aria-labelledby:引用另一个元素的文本——适用于存在可见标签的场景
<section aria-labelledby="section-title">
  <h2 id="section-title">Recent Orders</h2>
  {/* content */}
</section>

aria-describedby

aria-describedby

tsx
// Provides supplementary description beyond the label
<button
  aria-describedby="delete-warning"
  onClick={handleDelete}
>
  Delete account
</button>
<p id="delete-warning">This action cannot be undone.</p>
tsx
// 提供标签之外的补充描述
<button
  aria-describedby="delete-warning"
  onClick={handleDelete}
>
  Delete account
</button>
<p id="delete-warning">This action cannot be undone.</p>

aria-live for Dynamic Content

aria-live用于动态内容

tsx
// Use aria-live to announce content that updates without a page reload
// polite: waits for user to finish current action before announcing
// assertive: interrupts immediately — use only for urgent errors

export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
  return (
    <div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
      {message}
    </div>
  );
}
tsx
// 使用aria-live宣布无需页面刷新即可更新的内容
// polite:等待用户完成当前操作后再宣布
// assertive:立即中断——仅用于紧急错误

export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
  return (
    <div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
      {message}
    </div>
  );
}

aria-expanded and aria-controls

aria-expanded和aria-controls

tsx
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  const contentId = useId();

  return (
    <div>
      <button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
        {title}
      </button>
      <div id={contentId} hidden={!isOpen}>
        {children}
      </div>
    </div>
  );
}
tsx
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  const contentId = useId();

  return (
    <div>
      <button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
        {title}
      </button>
      <div id={contentId} hidden={!isOpen}>
        {children}
      </div>
    </div>
  );
}

Keyboard Navigation

键盘导航

Every interactive element must be reachable and operable by keyboard alone.
每个交互元素必须仅通过键盘即可访问和操作。

Custom Dropdown

自定义下拉菜单

tsx
export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const listId = useId();

  if (!options.length) return null;

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(i => Math.min(i + 1, options.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen) onSelect(options[activeIndex]);
        setIsOpen(prev => !prev);
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  };

  return (
    <div
      role="combobox"
      aria-expanded={isOpen}
      aria-haspopup="listbox"
      aria-controls={listId}
      tabIndex={0}
      onKeyDown={handleKeyDown}
      onClick={() => setIsOpen(prev => !prev)}
    >
      <span>{options[activeIndex]}</span>
      {isOpen && (
        <ul id={listId} role="listbox">
          {options.map((option, index) => (
            <li
              key={option}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => {
                onSelect(option);
                setIsOpen(false);
              }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
tsx
export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const listId = useId();

  if (!options.length) return null;

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(i => Math.min(i + 1, options.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen) onSelect(options[activeIndex]);
        setIsOpen(prev => !prev);
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  };

  return (
    <div
      role="combobox"
      aria-expanded={isOpen}
      aria-haspopup="listbox"
      aria-controls={listId}
      tabIndex={0}
      onKeyDown={handleKeyDown}
      onClick={() => setIsOpen(prev => !prev)}
    >
      <span>{options[activeIndex]}</span>
      {isOpen && (
        <ul id={listId} role="listbox">
          {options.map((option, index) => (
            <li
              key={option}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => {
                onSelect(option);
                setIsOpen(false);
              }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Focus Management

焦点管理

Focus must move logically when UI state changes — especially for modals and route transitions.
当UI状态变化时,焦点必须按逻辑移动——尤其是在模态框和路由切换时。

Modal Focus Restoration

模态框焦点恢复

This example covers initial focus and restoration. For a full focus trap (Tab/Shift+Tab cycling within the modal), use a library like
focus-trap-react
which handles edge cases like dynamic content and nested portals.
tsx
export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Save currently focused element and move focus into modal
      previousFocusRef.current = document.activeElement as HTMLElement;
      modalRef.current?.focus();
    } else {
      // Restore focus to the element that opened the modal
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
      <h2 id="modal-title">{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}
此示例涵盖初始焦点和焦点恢复。如需完整的焦点陷阱(Tab/Shift+Tab在模态框内循环),请使用
focus-trap-react
这样的库,它能处理动态内容和嵌套门户等边缘情况。
tsx
export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 保存当前焦点元素并将焦点移至模态框内
      previousFocusRef.current = document.activeElement as HTMLElement;
      modalRef.current?.focus();
    } else {
      // 将焦点恢复到打开模态框的元素
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
      <h2 id="modal-title">{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

Images and Icons

图片与图标

tsx
// BAD: decorative icon announced as unlabeled image
<img src="/icon.svg" />

// GOOD: decorative image hidden from screen readers
<img src="/decoration.png" alt="" aria-hidden="true" />

// GOOD: meaningful image with descriptive alt text
<img src="/chart.png" alt="Monthly revenue increased 23% from January to March" />

// GOOD: icon button with accessible label
<button aria-label="Delete item">
  <TrashIcon aria-hidden="true" />
</button>
tsx
// 错误示例:装饰性图标被屏幕阅读器识别为未标记的图片
<img src="/icon.svg" />

// 正确示例:装饰性图片对屏幕阅读器隐藏
<img src="/decoration.png" alt="" aria-hidden="true" />

// 正确示例:有意义的图片带有描述性alt文本
<img src="/chart.png" alt="Monthly revenue increased 23% from January to March" />

// 正确示例:带有可访问标签的图标按钮
<button aria-label="Delete item">
  <TrashIcon aria-hidden="true" />
</button>

Reduced Motion

减少动画

Respect users who have requested reduced motion in their OS settings.
tsx
export function useReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setPrefersReduced(mq.matches);
    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return prefersReduced;
}

// Usage
export function AnimatedCard({ children }: { children: React.ReactNode }) {
  const reduceMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: reduceMotion ? 'none' : 'transform 300ms ease'
      }}
    >
      {children}
    </div>
  );
}
尊重在系统设置中请求减少动画的用户。
tsx
export function useReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setPrefersReduced(mq.matches);
    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return prefersReduced;
}

// 使用示例
export function AnimatedCard({ children }: { children: React.ReactNode }) {
  const reduceMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: reduceMotion ? 'none' : 'transform 300ms ease'
      }}
    >
      {children}
    </div>
  );
}

Anti-Patterns

反模式

tsx
// BAD: onClick on non-interactive element with no keyboard support
<div onClick={handleClick}>Click me</div>

// BAD: aria-label on a div that has no role
<div aria-label="Navigation">...</div>

// BAD: placeholder used as a substitute for label
<input placeholder="Enter your email" />

// BAD: positive tabIndex creates unpredictable tab order
<button tabIndex={3}>Submit</button>

// BAD: aria-hidden on a focusable element — keyboard users get trapped
<button aria-hidden="true">Open</button>

// BAD: role="button" on div without keyboard handler
<div role="button" onClick={handleClick}>Submit</div>
// Missing: tabIndex={0}, onKeyDown for Enter/Space
tsx
// 错误示例:非交互元素添加onClick但无键盘支持
<div onClick={handleClick}>Click me</div>

// 错误示例:无角色的div添加aria-label
<div aria-label="Navigation">...</div>

// 错误示例:用placeholder替代标签
<input placeholder="Enter your email" />

// 错误示例:正tabIndex导致不可预测的制表顺序
<button tabIndex={3}>Submit</button>

// 错误示例:可聚焦元素添加aria-hidden——键盘用户会陷入困境
<button aria-hidden="true">Open</button>

// 错误示例:div添加role="button"但无键盘处理函数
<div role="button" onClick={handleClick}>Submit</div>
// 缺失:tabIndex={0}、Enter/Space的onKeyDown处理

Checklist

检查清单

Before submitting any interactive component for review:
  • Every
    <input>
    ,
    <select>
    , and
    <textarea>
    has a connected
    <label>
    via
    htmlFor
    /
    id
  • Error messages are linked with
    aria-describedby
    and marked
    role="alert"
  • No
    onClick
    on
    <div>
    or
    <span>
    without
    role
    ,
    tabIndex
    , and
    onKeyDown
  • Icon-only buttons have
    aria-label
  • Decorative images use
    alt=""
    and
    aria-hidden="true"
  • Modals restore focus on close (for full focus trapping with Tab/Shift+Tab cycling, use a library like
    focus-trap-react
    )
  • Dynamic content updates use
    aria-live
  • prefers-reduced-motion
    is respected for animations
在提交任何交互组件进行审查前:
  • 每个
    <input>
    <select>
    <textarea>
    都通过
    htmlFor
    /
    id
    关联了
    <label>
  • 错误消息通过
    aria-describedby
    关联并标记为
    role="alert"
  • 不为
    <div>
    <span>
    添加无
    role
    tabIndex
    onKeyDown
    onClick
  • 仅图标按钮带有
    aria-label
  • 装饰性图片使用
    alt=""
    aria-hidden="true"
  • 模态框关闭时恢复焦点(如需Tab/Shift+Tab循环的完整焦点陷阱,请使用
    focus-trap-react
    等库)
  • 动态内容更新使用
    aria-live
  • 动画尊重
    prefers-reduced-motion
    设置

Related Skills

相关技能

  • frontend-patterns
    — general React component and state patterns
  • design-system
    — design token and component consistency
  • motion-ui
    — animation patterns with accessibility considerations
  • frontend-patterns
    —— 通用React组件和状态模式
  • design-system
    —— 设计令牌和组件一致性
  • motion-ui
    —— 考虑可访问性的动画模式