Keyboard Navigation: Accessible Interaction Patterns

Easy

Keyboard navigation is essential for accessibility - test your app without a mouse.

Quick Decision Guide

Keyboard Navigation Essentials:

1. Tab order: All interactive elements reachable via Tab 2. Focus visible: Clear focus indicator (outline) 3. Standard keys: Enter/Space for buttons, Escape for modals 4. Arrow keys: For menus, dropdowns, sliders 5. Focus management: Trap focus in modals, restore after close

Tools: Tab key, focus-visible, tabindex, keyboard event handlers

Result: Fully usable without mouse.

Focus Management

Tab Index

<!-- tabindex="0": In natural tab order -->
<div tabindex="0" role="button">Focusable</div>

<!-- tabindex="-1": Programmatically focusable only -->
<div tabindex="-1" ref={modalRef}>
  Modal (focused when opened)
</div>

<!-- tabindex="1+": DON'T USE (breaks tab order) -->
<button tabindex="2">Don't do this</button>

Native Focusable Elements

<!-- These are focusable by default (no tabindex needed) -->
<a href="">
<button>
<input>
<select>
<textarea>
<summary> (in <details>)

<!-- These are NOT focusable -->
<div>
<span>
<p>

Focus Indicators

/* ❌ Bad: Removing focus indicator */
button:focus {
  outline: none; /* NEVER do this! */
}

/* ✅ Good: Custom but visible */
button:focus {
  outline: 2px solid #0070f3;
  outline-offset: 2px;
}

/* ✅ Better: Only for keyboard (not mouse clicks) */
button:focus-visible {
  outline: 2px solid #0070f3;
  outline-offset: 2px;
}

/* No outline when clicked, only when tabbed */
button:focus:not(:focus-visible) {
  outline: none;
}

Programmatic Focus

function Modal({ isOpen }) {
  const modalRef = useRef();
  
  useEffect(() => {
    if (isOpen) {
      modalRef.current?.focus();
    }
  }, [isOpen]);
  
  return (
    <div ref={modalRef} tabIndex={-1}>
      Modal content
    </div>
  );
}

Keyboard Event Patterns

Button Activation

// Native button (handles Enter/Space automatically)
<button onClick={handleClick}>
  Click me
</button>

// Custom interactive element
function CustomButton({ onClick, children }) {
  const handleKeyDown = (e) => {
    // Space or Enter activates button
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault(); // Prevent scroll on Space
      onClick(e);
    }
  };
  
  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      onKeyDown={handleKeyDown}
    >
      {children}
    </div>
  );
}

Escape to Close

function Modal({ isOpen, onClose }) {
  useEffect(() => {
    const handleEscape = (e) => {
      if (e.key === 'Escape') {
        onClose();
      }
    };
    
    if (isOpen) {
      document.addEventListener('keydown', handleEscape);
      return () => document.removeEventListener('keydown', handleEscape);
    }
  }, [isOpen, onClose]);
  
  return isOpen && (
    <div role="dialog" aria-modal="true">
      Modal content
    </div>
  );
}

Toggle with Space/Enter

function Checkbox({ checked, onChange, label }) {
  const handleKeyDown = (e) => {
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault();
      onChange(!checked);
    }
  };
  
  return (
    <div
      role="checkbox"
      aria-checked={checked}
      tabIndex={0}
      onClick={() => onChange(!checked)}
      onKeyDown={handleKeyDown}
    >
      {label}
    </div>
  );
}

Arrow Key Navigation

Dropdown Menu

function Dropdown({ options, onSelect }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const refs = useRef([]);
  
  const handleKeyDown = (e) => {
    if (!isOpen && (e.key === 'ArrowDown' || e.key === 'Enter')) {
      e.preventDefault();
      setIsOpen(true);
      return;
    }
    
    if (!isOpen) return;
    
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        const nextIndex = Math.min(activeIndex + 1, options.length - 1);
        setActiveIndex(nextIndex);
        refs.current[nextIndex]?.focus();
        break;
        
      case 'ArrowUp':
        e.preventDefault();
        const prevIndex = Math.max(activeIndex - 1, 0);
        setActiveIndex(prevIndex);
        refs.current[prevIndex]?.focus();
        break;
        
      case 'Enter':
        e.preventDefault();
        onSelect(options[activeIndex]);
        setIsOpen(false);
        break;
        
      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        break;
        
      case 'Home':
        e.preventDefault();
        setActiveIndex(0);
        refs.current[0]?.focus();
        break;
        
      case 'End':
        e.preventDefault();
        const lastIndex = options.length - 1;
        setActiveIndex(lastIndex);
        refs.current[lastIndex]?.focus();
        break;
    }
  };
  
  return (
    <div>
      <button 
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
        aria-expanded={isOpen}
        aria-haspopup="listbox"
      >
        Select option
      </button>
      {isOpen && (
        <ul role="listbox" onKeyDown={handleKeyDown}>
          {options.map((option, i) => (
            <li
              key={i}
              ref={el => refs.current[i] = el}
              role="option"
              tabIndex={-1}
              aria-selected={i === activeIndex}
              onClick={() => {
                onSelect(option);
                setIsOpen(false);
              }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Tabs Navigation

function Tabs({ tabs }) {
  const [selected, setSelected] = useState(0);
  const tabRefs = useRef([]);
  
  const handleKeyDown = (e) => {
    let newIndex = selected;
    
    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        newIndex = selected === 0 ? tabs.length - 1 : selected - 1;
        break;
      case 'ArrowRight':
        e.preventDefault();
        newIndex = selected === tabs.length - 1 ? 0 : selected + 1;
        break;
      case 'Home':
        e.preventDefault();
        newIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }
    
    setSelected(newIndex);
    tabRefs.current[newIndex]?.focus();
  };
  
  return (
    <div>
      <div role="tablist" onKeyDown={handleKeyDown}>
        {tabs.map((tab, i) => (
          <button
            key={i}
            ref={el => tabRefs.current[i] = el}
            role="tab"
            aria-selected={selected === i}
            aria-controls={`panel-${i}`}
            tabIndex={selected === i ? 0 : -1}
            onClick={() => setSelected(i)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab, i) => (
        <div
          key={i}
          id={`panel-${i}`}
          role="tabpanel"
          hidden={selected !== i}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Focus Trap (Modals)

Trap Focus in Modal

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef();
  const previousFocusRef = useRef();
  
  useEffect(() => {
    if (isOpen) {
      // Save currently focused element
      previousFocusRef.current = document.activeElement;
      
      // Focus modal
      modalRef.current?.focus();
      
      // Cleanup: restore focus
      return () => {
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen]);
  
  const handleKeyDown = (e) => {
    if (e.key === 'Escape') {
      onClose();
      return;
    }
    
    if (e.key === 'Tab') {
      const focusableElements = modalRef.current.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      
      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];
      
      if (e.shiftKey) {
        // Shift+Tab: If on first, go to last
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        }
      } else {
        // Tab: If on last, go to first
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    }
  };
  
  if (!isOpen) return null;
  
  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}
      onKeyDown={handleKeyDown}
    >
      {children}
    </div>
  );
}

Using react-focus-lock

import FocusLock from 'react-focus-lock';

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  
  return (
    <FocusLock returnFocus>
      <div role="dialog" aria-modal="true">
        {children}
      </div>
    </FocusLock>
  );
}

Testing & Best Practices

Keyboard Testing Checklist

Tab through entire page

All interactive elements focusable?
Focus indicator visible?
Logical tab order?

Test without mouse

Can you complete all tasks?
Are all features accessible?

Standard keys work

Enter/Space activate buttons
Escape closes modals
Arrow keys navigate menus

Focus management

Focus trapped in modals?
Focus restored after modal close?
Focus moves to appropriate element after delete?

Keyboard Shortcuts Reference

Tab              - Next focusable element
Shift+Tab        - Previous focusable element
Enter            - Activate links, buttons, submit forms
Space            - Activate buttons, check checkboxes
Escape           - Close dialogs, cancel actions
Arrow keys       - Navigate menus, lists, tabs, sliders
Home             - First item in list/menu
End              - Last item in list/menu
Page Up/Down     - Scroll content

Common Mistakes

// ❌ No keyboard support
<div onClick={handleClick}>Click me</div>

// ❌ No focus indicator
button:focus { outline: none; }

// ❌ Wrong tab order
<button tabindex="3">Third</button>
<button tabindex="1">First</button>

// ✅ Proper keyboard support
<button onClick={handleClick}>
  Click me
</button>

// ✅ Visible focus
button:focus-visible {
  outline: 2px solid blue;
}

// ✅ Natural tab order
<button>First</button>
<button>Second</button>