Siddhant Deval
Siddhant Deval
frontend5 min read

Deterministic Keyboard Navigation & Focus State Engines: Traps, Restoration & Roving TabIndex

Keyboard accessibility is a deterministic state machine. This article builds a production-grade, dependency-free focus trap engine in React and TypeScript, analyzes native <dialog> trade-offs, engineers fallback focus restoration cascades when trigger elements are destroyed, and compares roving tabIndex with aria-activedescendant.

Series·Part 3 of 6

Frontend Accessibility Architecture: From Semantic DOM Foundations to Resilient Accessible State Engines

Keyboard Navigation & Focus State Machines: Architecting an inert-Based Focus Guard

Parts 1 and 2 established the multi-modal runtime model, the AXTree compilation pipeline, and the AccName waterfall. The portal shell has correct landmark structure and semantically associated form fields. Part 2 left an open technical tension: how does the application manage which region owns focus at any moment — especially when modals open, drawers slide in, and dialogs close? The answer to that question is not a CSS utility class. It is a formal state machine.

The most common class of keyboard accessibility failures — invisible focus traps, focus loss on dialog close, roving-tabindex widgets that arrow-key to nowhere, focus restoration that points to a deleted element — share a single root cause: focus management is implemented as ad-hoc imperative code scattered across event handlers, rather than as an explicit, exhaustive finite-state automaton that owns focus as a first-class concern.

This article builds that automaton: a FocusStateMachine backed by useReducer, integrated with the inert attribute, and wired to a focus-visible CSS strategy that eliminates both invisible focus (no ring) and intrusive focus (ring on mouse click).


1. Focus as a Finite-State Machine

The keyboard focus surface of the Portal Canvas operates in a finite set of states. At any moment, exactly one of these states is active:

Mental Model Check

Focus state is not a boolean (isModalOpen). It is a machine with a well-defined set of states, a set of valid transitions between them, and a set of invariants that must hold in each state (e.g., "when MODAL_OPEN, all content outside the modal must have inert"). Modeling it as a state machine forces the author to enumerate every possible transition and handle it explicitly.

1.1 The State Machine Specification

TYPESCRIPT
// Focus machine states
type FocusState =
  | { type: 'PORTAL_IDLE' }
  | { type: 'MODAL_OPEN'; triggerId: string; modalId: string }
  | { type: 'CONFIRM_DIALOG'; parentTriggerId: string; dialogId: string }
  | { type: 'DRAWER_OPEN'; triggerId: string; drawerId: string }
  | { type: 'COMPOSITE_ACTIVE'; containerId: string; activeIndex: number };

// Actions that drive state transitions
type FocusAction =
  | { type: 'OPEN_MODAL'; triggerId: string; modalId: string }
  | { type: 'CLOSE_MODAL' }
  | { type: 'OPEN_CONFIRM'; parentTriggerId: string; dialogId: string }
  | { type: 'CLOSE_CONFIRM' }
  | { type: 'OPEN_DRAWER'; triggerId: string; drawerId: string }
  | { type: 'CLOSE_DRAWER' }
  | { type: 'ENTER_COMPOSITE'; containerId: string }
  | { type: 'COMPOSITE_MOVE'; delta: -1 | 1; total: number }
  | { type: 'EXIT_COMPOSITE' };

function focusReducer(state: FocusState, action: FocusAction): FocusState {
  switch (action.type) {
    case 'OPEN_MODAL':
      // Only valid from PORTAL_IDLE
      if (state.type !== 'PORTAL_IDLE') return state;
      return { type: 'MODAL_OPEN', triggerId: action.triggerId, modalId: action.modalId };

    case 'CLOSE_MODAL':
      if (state.type !== 'MODAL_OPEN') return state;
      return { type: 'PORTAL_IDLE' };

    case 'OPEN_CONFIRM':
      if (state.type !== 'MODAL_OPEN') return state;
      return { type: 'CONFIRM_DIALOG', parentTriggerId: action.parentTriggerId, dialogId: action.dialogId };

    case 'CLOSE_CONFIRM':
      if (state.type !== 'CONFIRM_DIALOG') return state;
      // Return to MODAL_OPEN, not to PORTAL_IDLE
      return { type: 'MODAL_OPEN', triggerId: state.parentTriggerId, modalId: '' };

    case 'OPEN_DRAWER':
      if (state.type !== 'PORTAL_IDLE') return state;
      return { type: 'DRAWER_OPEN', triggerId: action.triggerId, drawerId: action.drawerId };

    case 'CLOSE_DRAWER':
      if (state.type !== 'DRAWER_OPEN') return state;
      return { type: 'PORTAL_IDLE' };

    case 'ENTER_COMPOSITE':
      if (state.type !== 'PORTAL_IDLE') return state;
      return { type: 'COMPOSITE_ACTIVE', containerId: action.containerId, activeIndex: 0 };

    case 'COMPOSITE_MOVE': {
      if (state.type !== 'COMPOSITE_ACTIVE') return state;
      const next = (state.activeIndex + action.delta + action.total) % action.total;
      return { ...state, activeIndex: next };
    }

    case 'EXIT_COMPOSITE':
      if (state.type !== 'COMPOSITE_ACTIVE') return state;
      return { type: 'PORTAL_IDLE' };

    default:
      return state;
  }
}

2. The inert Attribute — DOM-Level Focus Containment

Before inert, focus trapping required a complex JavaScript implementation: intercepting Tab and Shift+Tab, querying all focusable descendants, and clamping focus at the boundaries. This implementation was brittle, missed dynamically added focusable elements, and did not prevent screen readers in browse mode from reading outside the dialog.

inert solves all of these at the browser engine level.

2.1 What inert Does

Adding inert to a DOM element:

  1. Removes all descendants from the tab order — as if every descendant had tabindex="-1" and pointer-events: none
  2. Removes all descendants from the AXTree — screen readers in browse mode cannot navigate into the region
  3. Prevents pointer events — clicks pass through to elements beneath (or are eaten)
  4. Is inherited: adding inert to a parent is sufficient; no need to mark every focusable descendant
TYPESCRIPT
// ❌ Manual focus trap — misses dynamically added elements, ignores browse mode
function DialogFocusTrap({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const focusables = ref.current?.querySelectorAll<HTMLElement>(
      'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    // 20+ lines of Tab/Shift+Tab interception logic...
    // Misses <details>, <summary>, contentEditable, and dynamically added elements
  }, []);

  return <div ref={ref}>{children}</div>;
}

// ✅ inert-based containment — browser-native, covers browse mode and tab order
function Dialog({ isOpen, triggerId, onClose, children }: DialogProps) {
  const { dispatch } = useFocusMachine();

  useEffect(() => {
    if (isOpen) {
      dispatch({ type: 'OPEN_MODAL', triggerId, modalId: 'settings-dialog' });
    }
  }, [isOpen, triggerId, dispatch]);

  // Managed by FocusGuard — see section 3
  return (
    <div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
      {children}
    </div>
  );
}

2.2 Focus Guard — The inert Controller

The Focus Guard reads the current focus machine state and applies inert to the correct regions:

TYPESCRIPT
function FocusGuard() {
  const { state } = useFocusMachine();

  useEffect(() => {
    const shell = document.getElementById('portal-shell');
    const modal = document.getElementById('modal-region');
    const drawer = document.getElementById('drawer-region');

    if (!shell || !modal || !drawer) return;

    switch (state.type) {
      case 'PORTAL_IDLE':
        // No region is inert — full portal is navigable
        shell.removeAttribute('inert');
        modal.setAttribute('inert', '');
        drawer.setAttribute('inert', '');
        break;

      case 'MODAL_OPEN':
      case 'CONFIRM_DIALOG':
        // Portal shell and drawer are inert — only modal region is live
        shell.setAttribute('inert', '');
        modal.removeAttribute('inert');
        drawer.setAttribute('inert', '');
        // Focus initial element inside modal
        const firstFocusable = modal.querySelector<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        firstFocusable?.focus();
        break;

      case 'DRAWER_OPEN':
        shell.setAttribute('inert', '');
        modal.setAttribute('inert', '');
        drawer.removeAttribute('inert');
        drawer.querySelector<HTMLElement>('[data-focus-initial]')?.focus();
        break;

      case 'COMPOSITE_ACTIVE':
        // Portal remains interactive; composite widget manages its own internal focus
        shell.removeAttribute('inert');
        modal.setAttribute('inert', '');
        drawer.setAttribute('inert', '');
        break;
    }
  }, [state]);

  return null;
}

2.3 Focus Restoration — Returning Focus to the Trigger

When a modal or drawer closes, focus must return to the element that opened it. Loss of focus (focus falling to <body>) disorients keyboard and screen reader users who now have no indication of where they are in the page.

TYPESCRIPT
function useFocusRestoration(triggerId: string, isActive: boolean) {
  useEffect(() => {
    if (isActive) return; // Modal is open — do not restore yet

    // Modal just closed — restore focus to the trigger element
    const trigger = document.getElementById(triggerId);
    if (trigger) {
      trigger.focus();
    } else {
      // Trigger element was deleted while dialog was open (e.g., item removed from list)
      // Fallback: focus main landmark
      document.getElementById('main-content')?.focus();
    }
  }, [isActive, triggerId]);
}
Crucial Requirement

Focus restoration must account for the case where the trigger element no longer exists when the dialog closes. This happens when a dialog is opened from a list item that is then deleted (a common pattern in confirmation delete dialogs). Always implement a fallback focus target — the <main> element or the closest surviving ancestor is the correct fallback.


3. focus-visible — Eliminating the Focus Ring Dilemma

The legacy solution to "focus rings look ugly on mouse clicks" was outline: none — which destroys keyboard navigation for sighted keyboard users. The correct solution is :focus-visible.

3.1 How :focus-visible Works

The browser applies :focus-visible only when the focus was received through keyboard or programmatic navigation, not mouse or touch. This enables:

  • Mouse users: no visible focus ring (they have a pointer)
  • Keyboard users: always visible focus ring
  • Programmatic focus: controlled by context — focus triggered from a keyboard action shows the ring; focus triggered from a mouse click does not
CSS
/* ❌ The wrong answer to "focus rings are ugly" */
* {
  outline: none; /* Destroys keyboard navigation */
}

/* ❌ Slightly better but still wrong — removes ring entirely on touch too */
:focus:not(:focus-visible) {
  outline: none;
}

/* ✅ Correct — mouse focus has no ring; keyboard focus has a clear, high-contrast ring */
:focus {
  outline: none; /* Reset default */
}

:focus-visible {
  outline: 3px solid #58a6ff; /* Cyan ring — meets WCAG 2.4.11 (3:1 contrast, ≥2px) */
  outline-offset: 2px;
  border-radius: 4px; /* Match element border-radius for visual coherence */
}

3.2 WCAG 2.4.11 Focus Appearance (WCAG 2.2)

WCAG 2.2 introduced the Focus Appearance success criterion (2.4.11 AA), which specifies minimum requirements for focus indicators:

Requirement Minimum Value Recommended
Area ≥ perimeter of the focused element × 2px Full outline
Contrast ≥ 3:1 between focused and unfocused states 4.5:1
Offset Allows separation from element 2–4px
CSS
/* ✅ WCAG 2.4.11 compliant focus ring for the Portal Canvas */
:focus-visible {
  /* 3px ring at 2px offset provides sufficient visible area around any element */
  outline: 3px solid #58a6ff; /* #58a6ff on #0d1117 = 4.7:1 contrast ✅ */
  outline-offset: 2px;
}

/* High Contrast Mode — let Windows override with system colors */
@media (forced-colors: active) {
  :focus-visible {
    outline: 3px solid ButtonText;
    outline-offset: 2px;
  }
}

4. Roving tabindex for Composite Widgets

Composite widgets — toolbars, tab lists, tree views, menus — follow a different focus model than single interactive controls. Instead of every item being individually Tab-focusable, the widget receives a single Tab stop and arrow keys move focus internally.

4.1 The Roving tabindex Pattern

At any moment, exactly one item in the composite widget has tabindex="0" (the "active" item). All others have tabindex="-1". Arrow key handlers transfer tabindex="0" to the new active item and .focus() it.

TYPESCRIPT
interface CompositeItem {
  id: string;
  label: string;
}

function useRovingTabindex(items: CompositeItem[]) {
  const { state, dispatch } = useFocusMachine();
  const itemRefs = useRef<Map<string, HTMLElement>>(new Map());

  const activeIndex =
    state.type === 'COMPOSITE_ACTIVE' ? state.activeIndex : 0;

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent, containerId: string) => {
      if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
        e.preventDefault();
        dispatch({ type: 'COMPOSITE_MOVE', delta: 1, total: items.length });
      } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
        e.preventDefault();
        dispatch({ type: 'COMPOSITE_MOVE', delta: -1, total: items.length });
      } else if (e.key === 'Home') {
        e.preventDefault();
        dispatch({ type: 'ENTER_COMPOSITE', containerId });
      } else if (e.key === 'Escape' || e.key === 'Tab') {
        dispatch({ type: 'EXIT_COMPOSITE' });
      }
    },
    [dispatch, items.length]
  );

  // Side effect: move DOM focus to the newly active item
  useEffect(() => {
    if (state.type !== 'COMPOSITE_ACTIVE') return;
    const item = items[state.activeIndex];
    if (item) {
      itemRefs.current.get(item.id)?.focus();
    }
  }, [state, items]);

  const getItemProps = (item: CompositeItem, index: number) => ({
    ref: (el: HTMLElement | null) => {
      if (el) itemRefs.current.set(item.id, el);
      else itemRefs.current.delete(item.id);
    },
    tabIndex: index === activeIndex ? 0 : -1,
    onKeyDown: (e: React.KeyboardEvent) => handleKeyDown(e, state.type === 'COMPOSITE_ACTIVE' ? state.containerId : ''),
    onFocus: () => {
      if (state.type !== 'COMPOSITE_ACTIVE') {
        dispatch({ type: 'ENTER_COMPOSITE', containerId: 'toolbar' });
      }
    },
  });

  return { getItemProps, activeIndex };
}

4.2 Keyboard Interaction Model

The WAI-ARIA Authoring Practices Guide (APG) specifies the required keyboard interaction for each widget type:

Widget Navigation Action Exit
Toolbar ←/→ arrows Enter / Space Tab (exits to next widget)
Tablist ←/→ arrows Automatic (on arrow key) Tab (moves into tab panel)
Tree view ↑/↓ arrows, → expand, ← collapse Enter selects Escape or Tab
Menu ↑/↓ arrows Enter selects Escape (closes, returns to trigger)
Listbox ↑/↓ arrows Space toggles Escape or Tab
Pro Tip & Optimization

Roving tabindex widgets must implement wrapping: ArrowDown on the last item wraps to the first, and ArrowUp on the first wraps to the last. The modulo arithmetic (currentIndex + delta + total) % total handles both directions correctly. Do not use Math.max(0, index - 1) — that prevents wrapping and violates the APG specification.


Summary

Concept Rule
Focus management is a formal finite-state automaton; modal, drawer, and composite widget states are mutually exclusive and require explicit transition logic. Model focus state with useReducer; never implement focus transitions as ad-hoc imperative code.
The inert attribute removes elements from both the tab order and the AXTree at the browser engine level, eliminating the need for JavaScript-based focus trapping. Use inert to lock non-active regions; a Focus Guard reads machine state and applies inert accordingly.
:focus-visible correctly suppresses focus rings on mouse interaction while preserving them for keyboard navigation — the correct answer to "focus rings are ugly." Never write outline: none without a matching :focus-visible replacement.
Roving tabindex gives composite widgets a single Tab stop; arrow keys drive internal focus movement; the modulo-wrapped active index is a derived state from the focus machine. Implement roving tabindex by coupling the composite widget to the focus state machine's COMPOSITE_ACTIVE state.

What's Next

Part 4 implements production WAI-ARIA compound widgets: the APG-compliant combobox with aria-autocomplete="list" and the manual-activation tablist for the Portal Canvas settings panel. Both widgets are built from first principles using the keyboard interaction models specified in WAI-ARIA 1.2 and tested against VoiceOver, NVDA, and TalkBack.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Accessibility#a11y#Keyboard Navigation#Focus Trap#State Machines#React#TypeScript
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.