Frontend A11y

作者 affaan-mef648e01899b无许可证275K 个星标收录于 2026年10月8日更新于 2026年10月8日仓库3天前更新

Accessibility patterns for React and Next.js — semantic HTML, ARIA attributes, form labeling, keyboard navigation, focus management, and screen reader support. Use when building or reviewing forms, modals, dropdowns, tooltips, or tabs, fixing a11y lint or code-review findings, or wiring up keyboard navigation and focus management.

AI 生成的概览

面向 React 与 Next.js 的无障碍组件参考模式:语义化 HTML、ARIA、键盘导航与焦点管理。

功能
为 React 与 Next.js 前端提供无障碍指导与代码示例,涵盖表单标签关联、ARIA 属性、语义化 HTML、键盘导航、焦点管理、图片与图标以及减少动态效果。内容包含正反例代码片段、审查清单和常见反模式列表。产出的是说明与示例代码,而非文件或自动修复。
适用场景
适用于在 React 或 Next.js 中构建或审查表单、模态框、下拉菜单、工具提示或标签页时。也适合修复无障碍相关的 lint 或代码审查问题,以及实现键盘导航与焦点管理。
运行要求
不附带脚本或工具,仅为说明文档。示例假定存在 React 或 Next.js 项目,其中一处说明建议使用外部焦点陷阱库来实现完整的焦点陷阱。

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.

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

Form Accessibility

Missing htmlFor / id pairing and disconnected error messages are the most common issues flagged in code review.

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

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

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

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

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

ARIA Attributes

Use ARIA only when native HTML semantics are insufficient. Wrong ARIA is worse than no ARIA.

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>

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>

aria-live for Dynamic Content

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

aria-expanded and 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>  );}

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

Focus Management

Focus must move logically when UI state changes — especially for modals and route transitions.

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

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>

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

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

Related Skills

  • frontend-patterns — general React component and state patterns
  • design-system — design token and component consistency
  • motion-foundations and motion-patterns: animation patterns with accessibility considerations

来源与署名

来源:affaan-m/ecc位于skills/frontend-a11y提交ef648e0

许可证: 无许可证

内容归原作者所有。SourceWeft 从公开仓库中收录这些内容。

举报或申请下架