跳转到主要内容

Accessibility

Accessibility is not “sprinkle aria”. It is semantics, keyboard flow, focus management, and feedback working together.

Start with semantics

Accessibility is easiest when you do the “boring” basics: correct HTML elements, labels, and relationships. ARIA is a supplement, not the foundation.

  • Use <button> for actions and <a href> for navigation. Don’t use div onClick.
  • Every input needs a label (visible or aria-label) and a stable name/id.
  • If you add ARIA, ensure roles/states are correct and updated together with UI.

Keyboard interaction patterns

A fast way to ship accessible UI is to standardize keyboard behaviors for common patterns.

  • Menus/lists: Arrow keys move focus; Enter selects; Esc closes.
  • Dialogs: focus moves into dialog on open; returns to trigger on close.
  • Tabs: Arrow keys move between tabs; active tab controls panel visibility.

Focus management

  • Make focus visible: never remove outlines unless you replace them with an accessible style.
  • On route changes, move focus to the new page heading when appropriate.
  • On form submit errors, focus the first invalid field and announce a summary.

Make state perceivable (aria-live, role)

Users of assistive technology need feedback when something changes: loading, errors, saved states.

TypeScript
export function SaveStatus({ status }: { status: 'idle' | 'saving' | 'saved' | 'error' }) {
  return (
    <div aria-live="polite">
      {status === 'saving' ? 'Saving…' : null}
      {status === 'saved' ? 'Saved.' : null}
      {status === 'error' ? <span role="alert">Save failed.</span> : null}
    </div>
  );
}

Example: minimal accessible dialog

A dialog needs: proper role, aria-modal, labeled title, focus on open, and focus return on close.

TypeScript
import { useEffect, useId, useRef } from 'react';

export function Dialog({
  open,
  title,
  onClose,
  children,
}: {
  open: boolean;
  title: string;
  onClose: () => void;
  children: React.ReactNode;
}) {
  const titleId = useId();
  const panelRef = useRef<HTMLDivElement | null>(null);
  const triggerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!open) return;
    triggerRef.current = document.activeElement as HTMLElement | null;
    panelRef.current?.focus();
  }, [open]);

  useEffect(() => {
    if (open) return;
    triggerRef.current?.focus();
  }, [open]);

  if (!open) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby={titleId}
      className="fixed inset-0 bg-black/50 flex items-center justify-center p-4"
      onKeyDown={(e) => {
        if (e.key === 'Escape') onClose();
      }}
    >
      <div
        ref={panelRef}
        tabIndex={-1}
        className="bg-white rounded p-4 w-full max-w-md dark:bg-gray-800"
      >
        <div className="flex items-center justify-between gap-3">
          <h2 id={titleId} className="font-bold">
            {title}
          </h2>
          <button type="button" onClick={onClose} aria-label="Close">
            ×
          </button>
        </div>
        <div className="mt-3">{children}</div>
      </div>
    </div>
  );
}

Checklist

  • All interactive elements are reachable with Tab.
  • Focus is visible and returns to a sensible place after closing dialogs.
  • Form errors are announced (role=alert) and connected (aria-describedby).
  • No keyboard traps: Esc closes dialogs, focus returns to a sensible element.
  • Interactive controls have an accessible name (label/aria-label).

Further reading

Accessibility - Guides - React Docs - React 文档