跳到正文
DEV Community· Gaurav Guha·· 2 小時前AI 評分55

React 和網頁應用的鍵盤導覽模式指南

Keyboard Navigation Patterns for React and Web Apps

AI 導讀

文章整理 React 和網頁應用的鍵盤導覽模式,涵蓋按鍵預期操作、tabindex、焦點順序及可見焦點指示器。文中提供模態視窗的焦點陷阱、關閉後還原焦點、跳過連結及 roving tabindex 等 React 程式碼模式,也列出對話介面快捷鍵和常見無障礙錯誤。文章亦提到 WCAG 2.1 AA 的 2.1.1 和 2.4.7 要求,涉及鍵盤操作及焦點指示器。

正文

Unplug your mouse for five minutes and try using your app. Most web apps fall apart immediately because focus gets lost, trapped in the wrong container, or hidden behind an overlay.

Keyboard navigation serves screen reader users, people with motor disabilities or temporary injuries, and power users who expect standard keys to work. Under WCAG 2.1 AA (criterion 2.1.1), all functionality must be operable via keyboard.

Here are the core rules, traps, and code patterns needed to handle focus properly in React.

The Core Interaction Rules

Before writing custom handlers, stick to native keyboard expectations:

  • Tab / Shift+Tab: Moves focus forward and backward through interactive elements (<a href>, <button>, form controls, elements with tabindex="0").
  • Enter: Activates links, buttons, and submits forms.
  • Space: Activates buttons, toggles checkboxes, selects radios, opens select dropdowns. Never override Space scrolling when nothing interactive has focus.
  • Escape: Closes overlays (modals, dropdowns, popovers) and returns focus to the trigger.
  • Arrow Keys: Moves focus within a component (tabs, menus, grids). They should not move focus between unrelated components.

Focus Order and tabindex

Tab order follows visual reading order automatically when your DOM matches your visual layout.

A common pitfall is using CSS to reverse layout while leaving the DOM intact:

// Bad: Visual order doesn't match DOM order
<div className="flex flex-row-reverse">
  <button>Third visually, first in DOM</button>
  <button>Second visually, second in DOM</button>
  <button>First visually, third in DOM</button>
</div>

// Good: DOM order matches visual order
<div className="flex">
  <button>First</button>
  <button>Second</button>
  <button>Third</button>
</div>

Control tab order using these specific values:

  • tabindex="0": Adds an element into the natural tab order.
  • tabindex="-1": Removes an element from tab order, but allows programmatic focus via element.focus().
  • tabindex="1" or higher: Avoid these entirely. Positive values override the natural tab order and create unexpected navigation jumps.
// Good: Programmatically focusable but not in tab order
<div ref={errorRef} tabIndex={-1} role="alert">
  Something went wrong. Please try again.
</div>

// In error handler:
errorRef.current?.focus();

Focus Management Patterns

1. Modal Dialog Focus Trap

When a modal opens, focus must stay inside it until closed. Tab cycles between the first and last focusable elements, Escape closes it, and focus returns to the button that opened it.

import { useRef, useEffect } from 'react';

function Modal({ isOpen, onClose, triggerRef, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    if (!isOpen) return;

    const modal = modalRef.current;
    const focusableElements = modal.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    // Focus the first element when modal opens
    firstElement?.focus();

    function handleKeyDown(e) {
      if (e.key === 'Escape') {
        onClose();
        return;
      }

      if (e.key !== 'Tab') return;

      // Trap focus inside modal
      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    }

    modal.addEventListener('keydown', handleKeyDown);
    return () => modal.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);

  // Return focus to trigger when modal closes
  useEffect(() => {
    if (!isOpen) {
      triggerRef?.current?.focus();
    }
  }, [isOpen, triggerRef]);

  if (!isOpen) return null;

  return (
    <div role="dialog" aria-modal="true" ref={modalRef}>
      {children}
    </div>
  );
}

Libraries like Radix UI, Headless UI, and shadcn/ui implement this out of the box.

2. Focus Restoration

When closing a popover, dropdown, or drawer, restore focus to the trigger so the user does not get thrown back to the top of the body:

function Dropdown({ trigger, children }) {
  const [isOpen, setIsOpen] = useState(false);
  const triggerRef = useRef(null);

  function close() {
    setIsOpen(false);
    // Restore focus to trigger
    triggerRef.current?.focus();
  }

  return (
    <>
      <button ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
        {trigger}
      </button>
      {isOpen && (
        <div role="menu" onKeyDown={(e) => e.key === 'Escape' && close()}>
          {children}
        </div>
      )}
    </>
  );
}

3. Dynamic Content Focus

Deciding when to move focus depends on what updated:

  • Move focus: Validation errors requiring action, opened modals, or full-step changes in a multi-step wizard.
  • Do not move focus: Background toast notifications (use aria-live instead) or newly loaded items in an infinite scroll list.
// Error: Move focus to error message
const errorRef = useRef(null);

function handleSubmit() {
  const result = validate(formData);
  if (!result.valid) {
    setError(result.message);
    // Move focus to error after render
    requestAnimationFrame(() => errorRef.current?.focus());
  }
}

<div ref={errorRef} tabIndex={-1} role="alert">
  {error}
</div>

4. Skip Links

Skip links let keyboard users bypass repetitive top navigation bars and jump straight to the <main> tag. Keep it as the first focusable child in your root layout:

// In your root layout component
<a
  href="#main-content"
  className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:text-gray-900 focus:rounded-md focus:shadow-lg focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
  Skip to main content
</a>

{/* Later in the page */}
<main id="main-content" tabIndex={-1}>
  {/* Page content */}
</main>

Arrow Key Navigation

Use the roving tabindex pattern for composite components like tabs, menus, and toolbars. The active item receives tabindex="0", while siblings receive tabindex="-1".

Roving Tabindex for Tabs

function TabList({ tabs, activeTab, onTabChange }) {
  function handleKeyDown(e, index) {
    let newIndex;

    switch (e.key) {
      case 'ArrowRight':
        newIndex = (index + 1) % tabs.length;
        break;
      case 'ArrowLeft':
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    onTabChange(newIndex);
  }

  return (
    <div role="tablist">
      {tabs.map((tab, index) => (
        <button
          key={tab.id}
          role="tab"
          aria-selected={index === activeTab}
          tabIndex={index === activeTab ? 0 : -1}
          onKeyDown={(e) => handleKeyDown(e, index)}
          ref={(el) => {
            if (index === activeTab) el?.focus();
          }}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

Menus and Dropdowns

Arrow keys handle vertical movement inside a dropdown menu:

// Key bindings for a dropdown menu
function handleMenuKeyDown(e, items, focusedIndex, setFocusedIndex) {
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      setFocusedIndex(Math.min(focusedIndex + 1, items.length - 1));
      break;
    case 'ArrowUp':
      e.preventDefault();
      setFocusedIndex(Math.max(focusedIndex - 1, 0));
      break;
    case 'Home':
      e.preventDefault();
      setFocusedIndex(0);
      break;
    case 'End':
      e.preventDefault();
      setFocusedIndex(items.length - 1);
      break;
    case 'Enter':
    case ' ':
      e.preventDefault();
      items[focusedIndex]?.action();
      break;
  }
}

Component Interaction Matrix

Component Arrow Keys Tab Enter/Space Escape
Tabs Move between tabs Move into/out of tab list Activate tab -
Menu Move between items Move into/out of menu Activate item Close menu
Accordion Move between headers Move into/out of accordion Toggle panel -
Listbox Move between options Move into/out of listbox Select option Close (if popup)
Tree Up/Down between nodes, Left/Right expand/collapse Move into/out of tree Activate node -
Grid/Table Move between cells Move into/out of grid Activate cell -

Visible Focus Indicators

Removing default browser focus without an accessible replacement breaks navigation:

/* Bad: Removing focus with no replacement */
*:focus {
  outline: none;
}

/* Good: Custom focus indicator */
*:focus-visible {
  outline: 2px solid #685FD4;
  outline-offset: 2px;
}

Use :focus-visible instead of :focus so keyboard users get a clear outline while mouse users do not see it on click:

/* Only shows for keyboard navigation */
button:focus-visible {
  outline: 2px solid #685FD4;
  outline-offset: 2px;
  border-radius: 4px;
}

/* For inputs, always show focus (users need to know which field is active) */
input:focus {
  outline: 2px solid #685FD4;
  outline-offset: 2px;
}

Under WCAG 2.1 AA (criterion 2.4.7), focus rings should have at least a 2px outline thickness, maintain a 3:1 contrast ratio against the background, and stay visible in both light and dark modes.

AI Chat Interface Patterns

Chat interfaces need specific shortcuts:

  • Prompt Input: Enter submits, Shift+Enter creates a newline, Arrow Up on an empty input recalls the previous prompt, and Escape clears the input.
  • Streaming Responses: Do not pull focus away from the textarea while text generates. Add aria-live="polite" to the response wrapper so screen readers announce incoming text without interrupting user input.
  • Response Actions: Pressing Tab from the message moves to action buttons (copy, retry, feedback).

Common Mistakes to Avoid

1. Click Handlers on Non-Interactive Tags

// Bad: div with click handler but no keyboard support
<div onClick={handleAction}>Click me</div>

// Good: Use a button
<button onClick={handleAction}>Click me</button>

A <div> does not respond to Enter or Space and will not show up in the tab order.

2. Focus Inside Collapsed Elements

Collapsed accordions, inactive tabs, and closed menus must not keep focusable elements active in the DOM. Use hidden, display: none, or the inert attribute:

// Good: Hidden content is not focusable
<div hidden={!isOpen}>
  <button>This button is not focusable when panel is closed</button>
</div>

3. Missing Focus After Deletion

When a user deletes an item from a list or table row, focus should land on the next item, the previous item, or the container. Never let focus reset back to the top <body> element.


This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.

來源:DEV Community · dev.to