Skip to content
Unverified — AI-generated content. Help verify this page

Keyboard Navigation ​

Keyboard navigation is the foundation of accessible interaction. Every user who relies on a keyboard — including screen reader users, motor-impaired users, power users, and anyone without a pointing device — depends on correct keyboard behavior.

The Keyboard Interaction Model ​

Web keyboard navigation operates on two levels:

  1. Tab order: moving between interactive regions and widgets
  2. Arrow key navigation: moving within a widget (composite components)

The distinction is critical: Tab moves BETWEEN components; arrow keys move WITHIN a component.

Tab flow:
[Button 1] → [Input] → [Button 2] → [Tab widget] → [Button 3]
                                          ↑
                                    Tab stops here
                                    Arrow keys navigate
                                    between tabs

tabindex Values ​

ValueEffect
tabindex="0"Add to tab order at natural DOM position
tabindex="-1"Focusable programmatically, not in tab order
tabindex="1+"Positive tabindex — AVOID, creates maintainability nightmare
html
<!-- tabindex="0" — add non-native element to tab order -->
<div role="button" tabindex="0">Custom Button</div>

<!-- tabindex="-1" — focusable by script, not by Tab key -->
<!-- Used for modal content, roving tabindex patterns -->
<div id="modal-content" tabindex="-1">
  <!-- Dialog content, receives focus when modal opens -->
</div>

<!-- AVOID positive tabindex -->
<button tabindex="5">Don't do this</button>
<button tabindex="1">Or this</button>
<!-- These create parallel focus orders that conflict with DOM order -->

DOM Order = Focus Order ​

The tab order follows DOM order. Therefore: the DOM order must match the visual reading order.

html
<!-- FAIL: visual order differs from DOM order via CSS float -->
<style>
  .content { float: right; }
  .sidebar { float: left; }
</style>

<div class="content">Main content</div>  <!-- Visually RIGHT -->
<nav class="sidebar">Navigation</nav>    <!-- Visually LEFT -->
<!-- Tab: "Main content" first, then "Navigation" — backwards from visual -->

<!-- PASS: DOM order matches visual order -->
<nav class="sidebar">Navigation</nav>    <!-- Visually LEFT, first in DOM -->
<div class="content">Main content</div>  <!-- Visually RIGHT, second in DOM -->

CSS order property

order in Flexbox and Grid can reorder items visually without changing DOM order. This creates a mismatch between visual and keyboard order. Use order only for purely decorative reordering, never for functional order.

The Roving tabindex Pattern ​

For composite widgets (toolbars, tab lists, grids, menus), only ONE element should be in the tab order at a time. Users Tab into the widget, then use arrow keys to navigate within it, then Tab out.

typescript
// roving-tabindex.ts

export class RovingTabindex {
  private items: HTMLElement[] = [];
  private currentIndex = 0;
  private orientation: 'horizontal' | 'vertical' | 'both';

  constructor(
    container: HTMLElement,
    itemSelector: string,
    orientation: 'horizontal' | 'vertical' | 'both' = 'horizontal'
  ) {
    this.orientation = orientation;
    this.items = Array.from(container.querySelectorAll<HTMLElement>(itemSelector));
    this.setup();
    container.addEventListener('keydown', this.handleKeyDown);
  }

  private setup(): void {
    // Only first item in tab order, rest are -1
    this.items.forEach((item, i) => {
      item.setAttribute('tabindex', i === 0 ? '0' : '-1');
    });
  }

  private handleKeyDown = (e: KeyboardEvent): void => {
    const { key } = e;
    let direction: number | null = null;

    if (
      (this.orientation !== 'vertical' && key === 'ArrowRight') ||
      (this.orientation !== 'horizontal' && key === 'ArrowDown')
    ) {
      direction = 1;
    } else if (
      (this.orientation !== 'vertical' && key === 'ArrowLeft') ||
      (this.orientation !== 'horizontal' && key === 'ArrowUp')
    ) {
      direction = -1;
    } else if (key === 'Home') {
      this.moveTo(0);
      e.preventDefault();
      return;
    } else if (key === 'End') {
      this.moveTo(this.items.length - 1);
      e.preventDefault();
      return;
    }

    if (direction !== null) {
      e.preventDefault();
      const newIndex = (this.currentIndex + direction + this.items.length) % this.items.length;
      this.moveTo(newIndex);
    }
  };

  moveTo(index: number): void {
    // Remove current item from tab order
    this.items[this.currentIndex].setAttribute('tabindex', '-1');

    // Add new item to tab order and focus it
    this.currentIndex = index;
    const newItem = this.items[this.currentIndex];
    newItem.setAttribute('tabindex', '0');
    newItem.focus();
  }

  destroy(container: HTMLElement): void {
    container.removeEventListener('keydown', this.handleKeyDown);
  }
}

// Usage
const toolbar = document.querySelector<HTMLElement>('[role="toolbar"]')!;
const rovingTabindex = new RovingTabindex(toolbar, '[role="button"]', 'horizontal');

React Roving Tabindex ​

tsx
// components/Toolbar/Toolbar.tsx
import React, { useRef, useState, useCallback, KeyboardEvent } from 'react';

interface ToolbarItem {
  id: string;
  label: string;
  onClick: () => void;
  icon?: React.ReactNode;
  disabled?: boolean;
}

interface ToolbarProps {
  items: ToolbarItem[];
  label: string;
}

export function Toolbar({ items, label }: ToolbarProps) {
  const [activeIndex, setActiveIndex] = useState(0);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const enabledItems = items.filter(item => !item.disabled);

  const moveTo = useCallback((index: number) => {
    const newIndex = Math.max(0, Math.min(enabledItems.length - 1, index));
    setActiveIndex(newIndex);
    itemRefs.current[newIndex]?.focus();
  }, [enabledItems.length]);

  const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        moveTo((activeIndex + 1) % enabledItems.length);
        break;
      case 'ArrowLeft':
        e.preventDefault();
        moveTo((activeIndex - 1 + enabledItems.length) % enabledItems.length);
        break;
      case 'Home':
        e.preventDefault();
        moveTo(0);
        break;
      case 'End':
        e.preventDefault();
        moveTo(enabledItems.length - 1);
        break;
    }
  }, [activeIndex, enabledItems.length, moveTo]);

  return (
    <div
      role="toolbar"
      aria-label={label}
      onKeyDown={handleKeyDown}
    >
      {items.map((item, index) => (
        <button
          key={item.id}
          ref={(el) => { itemRefs.current[index] = el; }}
          tabIndex={index === activeIndex ? 0 : -1}
          onClick={item.onClick}
          disabled={item.disabled}
          aria-label={item.label}
        >
          {item.icon}
          <span className="sr-only">{item.label}</span>
        </button>
      ))}
    </div>
  );
}

Long pages need skip links so keyboard users can bypass repetitive navigation:

html
<!-- Skip link — visually hidden, appears on focus -->
<a href="#main-content" class="skip-link">
  Skip to main content
</a>

<!-- Other skip links if needed -->
<a href="#main-navigation" class="skip-link">Skip to navigation</a>
<a href="#search" class="skip-link">Skip to search</a>

<header>…</header>
<main id="main-content" tabindex="-1">
  <!-- tabindex="-1" allows programmatic focus -->
</main>
css
/* Skip link pattern */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: var(--color-surface-default);
  color: var(--color-text-primary);
  padding: var(--space-2) var(--space-4);
  text-decoration: none;
  font-weight: 600;
  z-index: 999;
  border-radius: 0 0 0.25rem 0;
  transition: top 0.15s;
}

.skip-link:focus {
  top: 0; /* Reveal on focus */
  outline: 2px solid var(--color-border-focus);
  outline-offset: 2px;
}

Keyboard Shortcuts ​

Application-level keyboard shortcuts must not conflict with browser/screen reader shortcuts:

typescript
// keyboard-shortcuts.ts

interface KeyboardShortcut {
  key: string;
  modifiers?: {
    ctrl?: boolean;
    meta?: boolean;  // Cmd on Mac
    alt?: boolean;
    shift?: boolean;
  };
  action: () => void;
  description: string;
  // Prevent in these contexts
  excludeWhen?: () => boolean;
}

class KeyboardShortcutManager {
  private shortcuts: Map<string, KeyboardShortcut> = new Map();
  private isActive = true;

  constructor() {
    document.addEventListener('keydown', this.handleKeyDown);
  }

  register(shortcut: KeyboardShortcut): void {
    const key = this.getKey(shortcut);
    this.shortcuts.set(key, shortcut);
  }

  unregister(shortcut: KeyboardShortcut): void {
    this.shortcuts.delete(this.getKey(shortcut));
  }

  private getKey(shortcut: KeyboardShortcut): string {
    const mods = shortcut.modifiers ?? {};
    const parts = [
      mods.ctrl ? 'ctrl' : '',
      mods.meta ? 'meta' : '',
      mods.alt ? 'alt' : '',
      mods.shift ? 'shift' : '',
      shortcut.key.toLowerCase(),
    ].filter(Boolean);
    return parts.join('+');
  }

  private handleKeyDown = (e: KeyboardEvent): void => {
    if (!this.isActive) return;

    // Don't fire shortcuts when typing in input/textarea
    const target = e.target as HTMLElement;
    const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
      || target.isContentEditable;

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

    const shortcut = this.shortcuts.get(key);

    if (shortcut) {
      // Allow single-key shortcuts only in non-input contexts
      const hasModifier = e.ctrlKey || e.metaKey || e.altKey;
      if (isTyping && !hasModifier) return;

      // Check exclusion condition
      if (shortcut.excludeWhen?.()) return;

      e.preventDefault();
      shortcut.action();
    }
  };

  disable(): void { this.isActive = false; }
  enable(): void { this.isActive = true; }

  destroy(): void {
    document.removeEventListener('keydown', this.handleKeyDown);
  }
}

// Usage
const shortcuts = new KeyboardShortcutManager();

shortcuts.register({
  key: '/',
  description: 'Open search',
  action: () => document.querySelector<HTMLElement>('#search')?.focus(),
  excludeWhen: () => !!document.querySelector('[role="dialog"]'), // Disable in modals
});

shortcuts.register({
  key: 'k',
  modifiers: { ctrl: true },
  description: 'Open command palette',
  action: () => openCommandPalette(),
});

Focus Indicators ​

The W3C recommends at minimum a 3:1 contrast ratio for focus indicators relative to adjacent colors (WCAG 2.4.11):

css
/* Remove default outline — provide custom visible indicator */
:focus {
  outline: none; /* Remove browser default */
}

/* FAIL: removed focus and not replaced */
/* Users cannot see where focus is */

/* PASS: custom focus indicator */
:focus-visible {
  outline: 2px solid var(--color-border-focus);
  outline-offset: 2px;
}

/* Enhanced: double-ring for any background */
:focus-visible {
  outline: 2px solid var(--color-focus-ring);
  outline-offset: 2px;
  box-shadow:
    0 0 0 2px white,       /* White offset ring — contrasts against dark */
    0 0 0 4px var(--color-focus-ring); /* Outer ring */
}

The :focus-visible pseudo-class shows the focus ring only when navigating by keyboard (not on mouse click), providing a clean experience for mouse users while maintaining keyboard accessibility.

War Story

A major e-commerce site removed outline: none globally as a CSS reset (a common anti-pattern) and never replaced it. Their screen reader tester found they had removed ALL visible focus indicators from 500+ interactive elements. Screen reader users couldn't tell where focus was. The WCAG report listed this as a critical violation affecting every page. The fix was global (2 CSS lines), but testing every component to ensure the custom focus styles were visible against all backgrounds took 2 weeks.

"What I cannot create, I do not understand." — Richard Feynman