| |

React 97 ⚛️ Keyboard Navigation and Focus Management

⏱️ Estimated read: 16 min | |

Keyboard navigation is not an optional enhancement layered on top of a mouse-driven interface. It is the primary interaction model for screen reader users, power users, and anyone who cannot use a pointing device. In React, the challenge is that focus is a property of the DOM, not of component state. React’s declarative model renders elements, but moving focus requires an imperative call to element.focus(). Managing that interaction—deciding which element receives focus when a component mounts, when a modal opens, when an arrow key is pressed—is the subject of this chapter.

Key point: The tab sequence should include only one stop per composite widget. Once inside, arrow keys move focus among the widget’s internal elements. This pattern—roving tabindex—is the standard mechanism for toolbars, tabs, listboxes, and menus, and it exists because tabbing through every element of a complex widget is unusable.


Why keyboard navigation and focus management matter

The screen reader discovery problem. A screen reader user navigates by moving focus. Elements that are not focusable are not discoverable through keyboard navigation. A custom dropdown that renders <div> elements with click handlers but no tabIndex and no keyboard event handlers is invisible to a screen reader user, even if it works perfectly with a mouse . The WAI-ARIA Authoring Practices Guide is explicit that all interactive components must be reachable via the keyboard, either through the tab sequence or through a component that is in the tab sequence .

The focus trap problem. A modal dialog that does not trap focus allows the user to tab behind the overlay into the page content that is visually hidden. The user loses context, the screen reader announces content that is not visible, and the dialog’s purpose is defeated. Focus trapping is the mechanism that keeps keyboard navigation within the dialog while it is open, and returns focus to the triggering element when it closes .

The roving tabindex problem. A toolbar with twenty buttons should not require twenty tab presses to pass through. The standard pattern is to include only one button in the tab sequence at a time—the active or most recently focused one—and use arrow keys to move focus among the buttons within the toolbar. When the user tabs away and back, focus returns to the button they last interacted with, not the first one . This requires managing tabIndex dynamically, which is where React’s state and refs come into play.

The focus restoration problem. When a dialog closes, focus should return to the element that opened it. Without restoration, focus falls back to the <body>, and the keyboard user must navigate from the top of the page to find their place again. The pattern is to store a reference to document.activeElement before opening the dialog and call .focus() on it during cleanup .


a. Moving Focus Imperatively with Refs

React does not provide a declarative way to move focus. The autoFocus attribute exists on form elements, but it is limited and does not work for arbitrary elements or conditional rendering. The standard approach is a ref attached to the target element and a useEffect that calls .focus() when the condition for focusing is met.

import { useRef, useEffect } from 'react';

function SearchInput({ isOpen }) {
  const inputRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      // Move focus to the input when the search panel opens
      inputRef.current?.focus();
    }
  }, [isOpen]);

  return <input ref={inputRef} type="search" placeholder="Search..." />;
}

The optional chaining (?.) is not decorative. On the first render, inputRef.current is null because the DOM node has not been created yet. The effect runs after the DOM is committed, so by the time isOpen changes and the effect re-runs, the ref is populated. The guard prevents a runtime error in the edge case where the element is conditionally unmounted .

A callback ref is an alternative when the focus should happen at the moment the element mounts, rather than in response to a state change. The callback receives the DOM node as its argument and runs during the commit phase.

function AutoFocusInput() {
  // Callback ref runs when the element is attached to the DOM
  const handleRef = (element) => {
    element?.focus();
  };

  return <input ref={handleRef} type="text" />;
}

The callback ref pattern is useful for focus on mount without a useEffect and without a dependency array. The trade-off is that the callback runs on every render where the ref is reattached, which can cause repeated focus calls if not guarded .


b. The Roving Tabindex Pattern

Roving tabindex is the standard mechanism for composite widgets. The rule is simple: exactly one element in the widget has tabIndex={0}, and all others have tabIndex={-1}. When the user presses an arrow key, focus moves to the adjacent element, and the tabIndex values are swapped: the newly focused element becomes 0 and the previously focused element becomes -1 .

import { useState, useRef } from 'react';

function Toolbar({ buttons }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const buttonRefs = useRef([]);

  const handleKeyDown = (event) => {
    let newIndex = activeIndex;

    if (event.key === 'ArrowRight') {
      // Wrap from last to first
      newIndex = (activeIndex + 1) % buttons.length;
    } else if (event.key === 'ArrowLeft') {
      // Wrap from first to last
      newIndex = (activeIndex - 1 + buttons.length) % buttons.length;
    } else if (event.key === 'Home') {
      newIndex = 0;
    } else if (event.key === 'End') {
      newIndex = buttons.length - 1;
    } else {
      return;
    }

    event.preventDefault();
    setActiveIndex(newIndex);
    // Move focus after React re-renders the tabIndex values
    buttonRefs.current[newIndex]?.focus();
  };

  return (
    <div role="toolbar" aria-label="Text formatting" onKeyDown={handleKeyDown}>
      {buttons.map((button, index) => (
        <button
          key={button.id}
          ref={(el) => (buttonRefs.current[index] = el)}
          tabIndex={index === activeIndex ? 0 : -1}
          onClick={button.onClick}
        >
          {button.label}
        </button>
      ))}
    </div>
  );
}

The preventDefault() on arrow keys is necessary because arrow keys scroll the page by default. Without it, pressing ArrowRight in the toolbar would both move focus and scroll the page horizontally, which is disorienting. The role="toolbar" and aria-label provide the semantics that tell the screen reader this is a toolbar and what it is for .

The same pattern applies to tabs, listboxes, and menus. The differences are in the ARIA roles and the selection behavior—tabs use aria-selected, menus use aria-activedescendant or roving tabindex, and listboxes use aria-selected with the option to use aria-activedescendant instead when focus stays on the listbox container .


c. Focus Trapping in Modals and Focus Restoration

A modal dialog must prevent focus from leaving the dialog while it is open. The standard implementation intercepts the Tab key, checks whether the focused element is the first or last focusable element in the dialog, and wraps focus to the opposite end. On close, focus returns to the element that triggered the dialog.

import { useEffect, useRef } from 'react';

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

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

    // Store the element that had focus before the modal opened
    triggerRef.current = document.activeElement;

    // Move focus into the modal
    const focusable = modalRef.current?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    focusable?.[0]?.focus();

    const handleKeyDown = (event) => {
      if (event.key === 'Escape') {
        onClose();
        return;
      }

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

      const elements = modalRef.current?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      if (!elements?.length) return;

      const first = elements[0];
      const last = elements[elements.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    };

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      // Restore focus to the trigger when the modal closes
      triggerRef.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div role="dialog" aria-modal="true" aria-labelledby="modal-title" ref={modalRef}>
      <h2 id="modal-title">{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

The aria-modal="true" attribute tells assistive technology that content outside the dialog is inert. The focus trap and the aria-modal attribute are complementary: the attribute communicates the intent, and the trap enforces it for keyboard users . The native <dialog> element with .showModal() provides both automatically in modern browsers, which is why it is the recommended approach for new projects where browser support allows .

The query selector for focusable elements should exclude disabled elements and elements with tabIndex="-1". The selector above is a simplified version; a production implementation should also exclude hidden elements and elements with display: none or visibility: hidden. The @usefy/use-focus-trap package and similar hooks handle these edge cases, including nested traps and zero-focusable-element scenarios .


Complete Example Session

// ============================================
// PART 1: FOCUS ON MOUNT WITH A REF
// ============================================
import { useRef, useEffect } from 'react';

function SearchPanel({ isOpen }) {
  const inputRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      inputRef.current?.focus();
    }
  }, [isOpen]);

  return <input ref={inputRef} type="search" />;
}


// ============================================
// PART 2: CALLBACK REF FOR IMMEDIATE FOCUS
// ============================================
function AutoFocusField() {
  const handleRef = (element) => {
    element?.focus();
  };
  return <input ref={handleRef} />;
}


// ============================================
// PART 3: ROVING TABINDEX FOR A TOOLBAR
// ============================================
import { useState, useRef } from 'react';

function Toolbar({ items }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const refs = useRef([]);

  const onKeyDown = (e) => {
    let next = activeIndex;
    if (e.key === 'ArrowRight') next = (activeIndex + 1) % items.length;
    else if (e.key === 'ArrowLeft') next = (activeIndex - 1 + items.length) % items.length;
    else if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = items.length - 1;
    else return;

    e.preventDefault();
    setActiveIndex(next);
    refs.current[next]?.focus();
  };

  return (
    <div role="toolbar" aria-label="Actions" onKeyDown={onKeyDown}>
      {items.map((item, i) => (
        <button
          key={item.id}
          ref={(el) => (refs.current[i] = el)}
          tabIndex={i === activeIndex ? 0 : -1}
        >
          {item.label}
        </button>
      ))}
    </div>
  );
}


// ============================================
// PART 4: FOCUS TRAP FOR A MODAL
// ============================================
function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);
  const triggerRef = useRef(null);

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

    triggerRef.current = document.activeElement;

    const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
    const focusable = modalRef.current?.querySelectorAll(selector);
    focusable?.[0]?.focus();

    const handleTab = (e) => {
      if (e.key !== 'Tab') return;
      const elements = modalRef.current?.querySelectorAll(selector);
      if (!elements?.length) return;

      const first = elements[0];
      const last = elements[elements.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };

    document.addEventListener('keydown', handleTab);
    return () => {
      document.removeEventListener('keydown', handleTab);
      triggerRef.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

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


// ============================================
// PART 5: ESCAPE KEY HANDLING
// ============================================
// Inside the modal effect, add:
// if (e.key === 'Escape') onClose();
// This is part of the same keydown listener


// ============================================
// PART 6: SKIP LINK FOR KEYBOARD USERS
// ============================================
// A skip link is the first focusable element on the page
// It allows keyboard users to jump past navigation
function SkipLink() {
  return (
    <a href="#main-content" className="skip-link">
      Skip to main content
    </a>
  );
}
// CSS: position absolute, top -40px; on focus, top 0


// ============================================
// PART 7: ARIA-ACTIVEDESCENDANT ALTERNATIVE
// ============================================
// When focus stays on the container (combobox, searchbox)
// Use aria-activedescendant instead of roving tabindex
<div
  role="listbox"
  tabIndex={0}
  aria-activedescendant={activeOptionId}
>
  {options.map(opt => (
    <div key={opt.id} id={opt.id} role="option">{opt.label}</div>
  ))}
</div>


// ============================================
// PART 8: DISABLED ELEMENTS IN COMPOSITE WIDGETS
// ============================================
// Use aria-disabled="true" to keep element focusable
// but announce it as disabled
<button aria-disabled="true" tabIndex={0}>
  Unavailable action
</button>
// The element is discoverable by keyboard and screen reader
// but does not perform an action


// ============================================
// PART 9: FOCUS RESTORATION ON UNMOUNT
// ============================================
// The cleanup function in useEffect restores focus
// when the modal unmounts or isOpen becomes false
return () => {
  triggerRef.current?.focus();
};


// ============================================
// PART 10: USING A FOCUS TRAP LIBRARY
// ============================================
// For production, a library handles edge cases:
// import { useFocusTrap } from '@usefy/use-focus-trap';
// const ref = useFocusTrap(isOpen, { onEscape: onClose });
// Handles nested traps, zero focusable elements, SSR

The ten parts move from basic focus on mount through the roving tabindex pattern and the modal focus trap. Part 7 shows the alternative to roving tabindex for comboboxes and searchboxes, where focus stays on the input and aria-activedescendant tracks the active option. Part 8 addresses the discoverability trade-off for disabled elements in composite widgets.


Quick Reference

Focus Management Patterns

PatternUse CaseMechanism
Ref + useEffectFocus on state changeref.current.focus()
Callback refFocus on mountCallback receives element
Roving tabindexComposite widgets (toolbar, tabs)One tabIndex=0, rest -1
aria-activedescendantCombobox, searchboxFocus stays on container
Focus trapModal, dialogIntercept Tab key

Roving Tabindex Rules

RuleDetail
One tab stopExactly one element with tabIndex={0}
Arrow keys move focusLeft/Right or Up/Down depending on orientation
Swap tabIndexFocused element becomes 0, previous becomes -1
Home/EndOptional, move to first/last element
WrapOptional, but recommended for toolbars

Focus Trap Checklist

CheckPurpose
Store document.activeElement on openEnables focus restoration
Focus first element on openMoves user into dialog
Intercept Tab at boundariesPrevents focus escaping
Handle EscapeStandard close mechanism
Restore focus on closeReturns user to trigger
aria-modal="true"Hides background from AT

Best Practices

✅ Do This:

// Guard ref access before focusing
inputRef.current?.focus();                                              // ✅
// Use roving tabindex for toolbars and tabs
tabIndex={index === activeIndex ? 0 : -1}                              // ✅
// Store the trigger element before opening a modal
triggerRef.current = document.activeElement;                           // ✅
// Prevent default on arrow keys in composite widgets
event.preventDefault();                                                // ✅
// Use aria-disabled instead of disabled for focusable disabled elements
aria-disabled="true"                                                   // ✅

❌ Don’t Do This:

// Focus an element without checking the ref
inputRef.current.focus();  // May be null on first render                // ❌
// Put every toolbar button in the tab sequence
<button tabIndex={0}>  // All buttons, no arrow navigation              // ❌
// Forget to restore focus when a modal closes
// Focus falls to body, user loses place                                // ❌
// Use positive tabIndex values
tabIndex={1}  // Disrupts natural DOM order                              // ❌

Common Pitfalls

PitfallWhy It HappensFix
Focus does not move on openuseEffect dependency missingInclude isOpen in deps
Focus moves but is lost on re-renderRef attached to conditional elementEnsure element exists before focusing
Arrow keys scroll the pageNo preventDefaultCall event.preventDefault()
Modal focus escapesNo trap or incomplete selectorIntercept Tab at boundaries
Focus not restored on closeCleanup missing .focus()Store trigger, restore in cleanup
Disabled element not discoverableUsed disabled attributeUse aria-disabled for focusable disabled

Real-World Examples

1. Focus Input on Panel Open

useEffect(() => { if (isOpen) inputRef.current?.focus(); }, [isOpen]);

2. Callback Ref for Mount Focus

const handleRef = (el) => el?.focus();

3. Roving Tabindex State

const [active, setActive] = useState(0);
tabIndex={i === active ? 0 : -1}

4. Arrow Key Handler

if (e.key === 'ArrowRight') { e.preventDefault(); moveNext(); }

5. Modal Trigger Storage

triggerRef.current = document.activeElement;

6. Focus Trap Boundary

if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }

7. Focus Restoration

return () => triggerRef.current?.focus();

8. Skip Link

<a href="#main" className="skip-link">Skip to content</a>

9. Aria-Activedescendant

<div role="listbox" aria-activedescendant={activeId} tabIndex={0}>

10. Disabled but Focusable

<button aria-disabled="true" tabIndex={0}>Unavailable</button>

Visual

Roving Tabindex Flow

┌──────────────────────────────────────────────────────────────┐
│  TOOLBAR WITH THREE BUTTONS                                  │
│                                                              │
│  Initial state (button 1 active):                           │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐                        │
│  │ Button 1│ │ Button 2│ │ Button 3│                        │
│  │ tab=0   │ │ tab=-1  │ │ tab=-1  │                        │
│  │ FOCUSED │ │         │ │         │                        │
│  └─────────┘ └─────────┘ └─────────┘                        │
│                                                              │
│  User presses ArrowRight:                                    │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐                        │
│  │ Button 1│ │ Button 2│ │ Button 3│                        │
│  │ tab=-1  │ │ tab=0   │ │ tab=-1  │                        │
│  │         │ │ FOCUSED │ │         │                        │
│  └─────────┘ └─────────┘ └─────────┘                        │
│                                                              │
│  User presses Tab to leave and Tab to return:               │
│  Focus returns to Button 2 (the last active), not Button 1   │
│                                                              │
│  Only one element is in the tab sequence at any time.        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Focus Trap in Modal

┌──────────────────────────────────────────────────────────────┐
│  MODAL OPENED                                                │
│                                                              │
│  ┌─────────────────────────────────────────┐                │
│  │ DIALOG                                  │                │
│  │                                         │                │
│  │  ┌─────────┐  ┌─────────┐  ┌─────────┐ │                │
│  │  │ Input   │  │ Cancel  │  │ Confirm │ │                │
│  │  │ FIRST   │  │         │  │ LAST    │ │                │
│  │  └─────────┘  └─────────┘  └─────────┘ │                │
│  │                                         │                │
│  │  Tab on LAST → wraps to FIRST           │                │
│  │  Shift+Tab on FIRST → wraps to LAST     │                │
│  │                                         │                │
│  └─────────────────────────────────────────┘                │
│                                                              │
│  ┌─────────────────────────────────────────┐                │
│  │ PAGE CONTENT (INERT)                    │                │
│  │ Tab cannot reach these elements         │                │
│  └─────────────────────────────────────────┘                │
│                                                              │
│  aria-modal="true" communicates the trap to AT.             │
│  On close: focus returns to the trigger button.              │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Focus Restoration Flow

┌──────────────────────────────────────────────────────────────┐
│  BEFORE MODAL OPENS                                          │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ Button          │  document.activeElement                │
│  │ "Open Settings" │  triggerRef.current = this             │
│  └────────┬────────┘                                        │
│           │ click                                            │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ MODAL OPENS     │  Focus moves to first element          │
│  │                 │  (input or close button)               │
│  └────────┬────────┘                                        │
│           │ user navigates, closes                           │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ MODAL CLOSES    │  Cleanup: triggerRef.current.focus()   │
│  └────────┬────────┘                                        │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ Button          │  Focus restored                        │
│  │ "Open Settings" │  Keyboard user continues from here     │
│  └─────────────────┘                                        │
│                                                              │
│  Without restoration, focus falls to <body>.                 │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Aria-Activedescendant vs. Roving Tabindex

┌──────────────────────────────────────────────────────────────┐
│  ROVING TABINDEX              ARIA-ACTIVEDESCENDANT          │
│                                                              │
│  Focus moves to each          Focus stays on the container   │
│  option element               (input, listbox)               │
│                                                              │
│  ┌─────────┐                  ┌─────────────────┐           │
│  │ Option 1│ ← focus          │ Combobox        │ ← focus   │
│  └─────────┘                  │ aria-activedesc │           │
│  ┌─────────┐                  └────────┬────────┘           │
│  │ Option 2│                           │                    │
│  └─────────┘                           ▼                    │
│  ┌─────────┐                  ┌─────────────────┐           │
│  │ Option 3│                  │ Option 1        │           │
│  └─────────┘                  │ Option 2 ← active│          │
│                               │ Option 3        │           │
│  Use for toolbars,            └─────────────────┘           │
│  tabs, menus                  Use for comboboxes,           │
│                               searchboxes, virtualized      │
│                               lists                           │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Focus APIref.current.focus()
Focus on mountCallback ref or useEffect
Roving tabindexOne tabIndex=0, rest -1
Arrow key handlingpreventDefault() + move focus + swap tabIndex
Focus trapIntercept Tab at first/last element
Focus restorationStore document.activeElement, restore on cleanup
aria-modalCommunicates inert background to AT
aria-activedescendantAlternative when focus stays on container
Disabled focusablearia-disabled="true" instead of disabled

Key takeaways:

  • Focus is imperative; React state is declarative. Moving focus requires a ref and a .focus() call. The effect or callback ref is the bridge between React’s render cycle and the DOM’s focus state.
  • The tab sequence should have one stop per composite widget. Roving tabindex is the mechanism: one element has tabIndex={0}, the rest have tabIndex={-1}, and arrow keys move focus and swap the values. This is the standard for toolbars, tabs, listboxes, and menus .
  • Modals must trap focus. Without a trap, keyboard users can tab behind the overlay into content they cannot see. The trap intercepts Tab at the boundaries and wraps focus to the opposite end. aria-modal="true" communicates the same intent to assistive technology .
  • Focus restoration is part of the modal lifecycle. Storing document.activeElement before the modal opens and calling .focus() on it during cleanup returns the user to their place. Without it, focus falls to the body and the user must navigate from the top .
  • aria-activedescendant is the alternative for comboboxes and virtualized lists. When focus must stay on an input or container, the active option is tracked with aria-activedescendant rather than moving DOM focus to each option .
  • The native <dialog> element provides focus trapping and Escape handling. For new projects with modern browser support, .showModal() is the recommended approach because it implements the trap, the backdrop, and the ARIA role without custom code .

Remember: Keyboard navigation is not a feature that can be added after the component works with a mouse. It is a property of the component’s structure: the tab sequence, the focus order, the key handlers, and the focus restoration. In React, these are managed through refs and effects, not through JSX attributes alone. The roving tabindex pattern exists because tabbing through every element of a complex widget is unusable; the focus trap exists because tabbing behind a modal is disorienting. The implementation details—preventDefault, tabIndex swapping, trigger storage, cleanup restoration—are the mechanics that make the accessibility intent real.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!