Skip to main content

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>