Skip to content
intermediatePhase 39 · Accessibility

Keyboard Navigation

Ensure all interactive elements are accessible via keyboard.

45m
0 problems
Topic Progress0%

Tab Order

Tab Order

Control the order keyboard users navigate through elements.

Natural Tab Order

<!-- Tab order follows DOM order -->
<button>First</button>
<input type="text" />
<button>Third</button>

Tab Index

<!-- tabindex="0": Add to tab order -->
<div tabindex="0" role="button">Custom Button</div>

<!-- tabindex="-1": Focusable but not in tab order -->
<div tabindex="-1" id="modal">Modal Content</div>

<!-- tabindex="1+": Avoid (forces tab order) -->
<!-- Don't use positive tabindex values -->

Semantic Elements

// Use native elements for built-in tab behavior
function Form() {
  return (
    <form>
      <input type="text" /> {/* Tabbable */}
      <select>{/* Tabbable */}</select>
      <button type="submit">Submit</button> {/* Tabbable */}
      <a href="/">Link</a> {/* Tabbable */}
    </form>
  );
}

// Custom elements need tabindex
function CustomButton({ onClick, children }) {
  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      onKeyDown={(e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          onClick();
        }
      }}
    >
      {children}
    </div>
  );
}

Roving Tab Index

// For composite widgets like menus
function Menu({ items }) {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <ul role="menu">
      {items.map((item, index) => (
        <li
          key={item.id}
          role="menuitem"
          tabIndex={index === activeIndex ? 0 : -1}
          onKeyDown={(e) => handleKeyDown(e, index)}
        >
          {item.label}
        </li>
      ))}
    </ul>
  );
}

Hidden Content

// Remove from tab order when hidden
function Modal({ isOpen, children }) {
  return isOpen ? (
    <div role="dialog" aria-modal="true">
      {children}
    </div>
  ) : null;
}

// Or use inert attribute
<div inert={isOpen ? false : true}>
  <button>Focusable</button>
</div>

Keyboard Shortcuts

Keyboard Shortcuts

Implement keyboard shortcuts for power users.

Basic Shortcuts

function useKeyboardShortcuts(shortcuts) {
  useEffect(() => {
    const handleKeyDown = (e) => {
      // Skip if inside input/textarea
      if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;

      const key = [
        e.ctrlKey && 'ctrl',
        e.shiftKey && 'shift',
        e.altKey && 'alt',
        e.key.toLowerCase(),
      ].filter(Boolean).join('+');

      if (shortcuts[key]) {
        e.preventDefault();
        shortcuts[key]();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [shortcuts]);
}

// Usage
function App() {
  useKeyboardShortcuts({
    'ctrl+s': () => saveDocument(),
    'ctrl+z': () => undo(),
    'ctrl+shift+z': () => redo(),
    'ctrl+p': () => printDocument(),
  });

  return <Editor />;
}

Command Palette

function CommandPalette({ isOpen, onClose }) {
  const [query, setQuery] = useState('');
  const [selectedIndex, setSelectedIndex] = useState(0);

  const commands = [
    { id: 'save', label: 'Save', shortcut: 'Ctrl+S', action: save },
    { id: 'open', label: 'Open', shortcut: 'Ctrl+O', action: open },
    { id: 'delete', label: 'Delete', shortcut: 'Del', action: deleteItem },
  ];

  const filtered = commands.filter(cmd =>
    cmd.label.toLowerCase().includes(query.toLowerCase())
  );

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

    const handleKeyDown = (e) => {
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setSelectedIndex(prev => Math.min(prev + 1, filtered.length - 1));
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        setSelectedIndex(prev => Math.max(prev - 1, 0));
      } else if (e.key === 'Enter') {
        e.preventDefault();
        filtered[selectedIndex]?.action();
        onClose();
      } else if (e.key === 'Escape') {
        onClose();
      }
    };

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

  if (!isOpen) return null;

  return (
    <div className="command-palette" role="dialog" aria-label="Command palette">
      <input
        type="text"
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          setSelectedIndex(0);
        }}
        placeholder="Type a command..."
        autoFocus
      />
      <ul role="listbox">
        {filtered.map((cmd, index) => (
          <li
            key={cmd.id}
            role="option"
            aria-selected={index === selectedIndex}
            className={index === selectedIndex ? 'selected' : ''}
          >
            <span>{cmd.label}</span>
            <kbd>{cmd.shortcut}</kbd>
          </li>
        ))}
      </ul>
    </div>
  );
}

Shortcut Documentation

function ShortcutHelp() {
  const shortcuts = [
    { keys: ['Ctrl', 'S'], action: 'Save' },
    { keys: ['Ctrl', 'Z'], action: 'Undo' },
    { keys: ['Ctrl', 'Shift', 'Z'], action: 'Redo' },
    { keys: ['Ctrl', '/'], action: 'Toggle comments' },
  ];

  return (
    <div className="shortcut-help">
      <h2>Keyboard Shortcuts</h2>
      <dl>
        {shortcuts.map((shortcut, i) => (
          <div key={i}>
            <dt>
              {shortcut.keys.map(key => (
                <kbd key={key}>{key}</kbd>
              )).reduce((prev, curr) => [prev, ' + ', curr])}
            </dt>
            <dd>{shortcut.action}</dd>
          </div>
        ))}
      </dl>
    </div>
  );
}

Custom Key Handlers

Custom Key Handlers

Handle keyboard interactions for custom components.

Custom Hook

// hooks/useKeyboard.js
function useKeyboard(handlers) {
  useEffect(() => {
    const handleKeyDown = (e) => {
      for (const [key, handler] of Object.entries(handlers)) {
        if (matchKey(e, key)) {
          handler(e);
          break;
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [handlers]);
}

function matchKey(event, key) {
  const parts = key.toLowerCase().split('+');
  const modifiers = parts.slice(0, -1);
  const keyName = parts[parts.length - 1];

  if (modifiers.includes('ctrl') && !event.ctrlKey) return false;
  if (modifiers.includes('shift') && !event.shiftKey) return false;
  if (modifiers.includes('alt') && !event.altKey) return false;

  return event.key.toLowerCase() === keyName;
}

Accessible List

function AccessibleList({ items, onSelect }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (e) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(prev => Math.min(prev + 1, items.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(prev => Math.max(prev - 1, 0));
        break;
      case 'Home':
        e.preventDefault();
        setActiveIndex(0);
        break;
      case 'End':
        e.preventDefault();
        setActiveIndex(items.length - 1);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        onSelect(items[activeIndex]);
        break;
    }
  };

  return (
    <ul
      role="listbox"
      tabIndex={0}
      onKeyDown={handleKeyDown}
    >
      {items.map((item, index) => (
        <li
          key={item.id}
          role="option"
          aria-selected={index === activeIndex}
          ref={index === activeIndex ? activeRef : null}
        >
          {item.label}
        </li>
      ))}
    </ul>
  );
}

Grid Navigation

function DataGrid({ rows, columns }) {
  const [activeCell, setActiveCell] = useState({ row: 0, col: 0 });

  const handleKeyDown = (e) => {
    const { row, col } = activeCell;

    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        setActiveCell({ row, col: Math.min(col + 1, columns.length - 1) });
        break;
      case 'ArrowLeft':
        e.preventDefault();
        setActiveCell({ row, col: Math.max(col - 1, 0) });
        break;
      case 'ArrowDown':
        e.preventDefault();
        setActiveCell({ row: Math.min(row + 1, rows.length - 1), col });
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveCell({ row: Math.max(row - 1, 0), col });
        break;
    }
  };

  return (
    <table
      role="grid"
      tabIndex={0}
      onKeyDown={handleKeyDown}
    >
      <thead>
        <tr>
          {columns.map(col => (
            <th key={col.id} scope="col">{col.label}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, rowIndex) => (
          <tr key={row.id}>
            {columns.map((col, colIndex) => (
              <td
                key={col.id}
                tabIndex={rowIndex === activeCell.row && colIndex === activeCell.col ? 0 : -1}
                aria-selected={rowIndex === activeCell.row && colIndex === activeCell.col}
              >
                {row[col.id]}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Practice Problems

0/3solved
Build Keyboard Navigation Component

Create a reusable React component implementing Keyboard Navigation. Include proper state management and accessibility.

Solution
// Production-ready component with:
// - Proper TypeScript types
// - Accessibility (ARIA)
// - Error boundaries
// - Loading states
// - Memoization where needed
Keyboard Navigation Testing

Write unit and integration tests for Keyboard Navigation using React Testing Library.

Solution
// Test coverage:
// 1. Rendering tests
// 2. Interaction tests
// 3. Edge case tests
// 4. Accessibility tests
Keyboard Navigation Performance

Optimize Keyboard Navigation for performance. Consider memoization, code splitting, and bundle size.

Solution
// Optimization techniques:
// 1. React.memo / useMemo / useCallback
// 2. Code splitting with lazy()
// 3. Virtual scrolling for lists
// 4. Image lazy loading
// 5. Bundle analysis

Quiz

1. What does tabindex="0" do?

Question 1 options

2. What is roving tabindex?

Question 2 options

3. What is the primary purpose of Keyboard Navigation?

Question 3 options

4. What is a common mistake when implementing Keyboard Navigation?

Question 4 options

Flashcards

Question

What is tab order?

Answer

The order keyboard users navigate through focusable elements on the page.

Question

What does tabindex="0" do?

Answer

Adds an element to the natural tab order.

Question

What is roving tabindex?

Answer

A technique for composite widgets where only the active item has tabindex="0".

Question

Why skip inputs in keyboard shortcuts?

Answer

To avoid interfering with normal typing in form fields.

Question

What is Keyboard Navigation?

Answer

Keyboard Navigation is a key concept in frontend development.

Revision Notes

Key Takeaways

  • 1.Use tabindex="0" to add custom elements to tab order
  • 2.Roving tabindex is best for composite widgets
  • 3.Always test keyboard navigation
  • 4.Skip form inputs when handling global shortcuts
  • 5.Document keyboard shortcuts for users

Interview Tips

  • Explain tabindex values and their effects
  • Discuss how to implement keyboard navigation for custom components
  • Know how to handle keyboard shortcuts properly

Cheat Sheet

Keyboard Navigation Cheat Sheet

Tab Index

  • tabindex="0": Add to tab order
  • tabindex="-1": Focusable, not in tab order
  • Avoid positive values

Keyboard Shortcuts

  • Check for modifier keys
  • Skip inputs/textareas
  • Use preventDefault()

Custom Handlers

  • Arrow keys for navigation
  • Enter/Space for activation
  • Escape for closing
  • Home/End for jumping