FrontCore
Accessibility

Keyboard Navigation Patterns

The keyboard interaction patterns keyboard and AT users expect — roving tabindex for composite widgets, focus trapping for dialogs, skip links, focus-visible vs focus, aria-expanded state, and the ARIA APG patterns for toolbars, tabs, and menus.

Keyboard Navigation Patterns

Overview

Keyboard navigation lets users interact with your UI using only a keyboard — no mouse required. It is a core pillar of web accessibility, required by WCAG 2.1, and critical for users who rely on screen readers, switch devices, or keyboard-only workflows.

If your app can't be fully operated without a mouse, it is broken for a meaningful portion of your users and likely non-compliant with accessibility law in many jurisdictions.

Good keyboard navigation means: every interactive element is reachable via Tab / Shift+Tab, focus is always visible, composite widgets follow ARIA Authoring Practices Guide (APG) patterns, and focus is managed programmatically when the UI changes.


How It Works

The browser maintains a focus ring — a pointer to the currently active element. Tab moves forward through the natural DOM order; Shift+Tab moves backward. Only natively interactive elements (<button>, <a href>, <input>, <select>, <textarea>) are in the tab order by default.

For complex widgets, two patterns extend this:

Roving tabindex: Only one element in a group has tabIndex={0} (is in the tab sequence) at a time. All others have tabIndex={-1}. Arrow keys move focus and update which element "owns" the tab stop. Used for: toolbars, radio groups, tab lists, tree views, grids.

Focus trapping: Inside a modal dialog, Tab and Shift+Tab wrap within the dialog's focusable elements rather than escaping to the page. The modern implementation uses the inert attribute on background content.

Key APIs:

  • element.focus() — move focus programmatically
  • tabIndex — control tab order membership
  • document.activeElement — read currently focused element
  • KeyboardEvent on keydown — handle arrow keys, Escape, Enter, Space

Code Examples

1. Roving Tabindex — Toolbar

The roving tabindex pattern is required for any composite widget where arrow keys navigate between items (toolbar, tab list, radio group, listbox, grid):

// components/Toolbar.tsx
"use client";
import { useRef } from "react";

const TOOLS = ["Bold", "Italic", "Underline", "Strikethrough"] as const;

export function Toolbar() {
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const activeIndex = useRef(0); // which item "owns" the tab stop

  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
    const total = TOOLS.length;
    let next = activeIndex.current;

    switch (e.key) {
      case "ArrowRight":
        next = (next + 1) % total;
        break;
      case "ArrowLeft":
        next = (next - 1 + total) % total;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = total - 1;
        break;
      default:
        return; // don't preventDefault for unhandled keys
    }

    e.preventDefault(); // prevent page scroll on arrow/Home/End

    // Update which element owns the tab stop
    itemRefs.current[activeIndex.current]?.setAttribute("tabindex", "-1");
    itemRefs.current[next]?.setAttribute("tabindex", "0");
    itemRefs.current[next]?.focus();
    activeIndex.current = next;
  }

  return (
    <div role="toolbar" aria-label="Text formatting" onKeyDown={handleKeyDown}>
      {TOOLS.map((tool, i) => (
        <button
          key={tool}
          ref={(el) => {
            itemRefs.current[i] = el;
          }}
          tabIndex={i === 0 ? 0 : -1} // only index 0 starts in tab order
          aria-pressed={false} // update with actual state
          onClick={() => {
            activeIndex.current = i;
          }}
        >
          {tool}
        </button>
      ))}
    </div>
  );
}

Home and End keys are required by the ARIA APG for toolbar and grid patterns. Arrow keys wrap at boundaries (last → first, first → last) for toolbar; they do NOT wrap for grids and tree views — check the APG for the specific pattern.


2. Tab List (Tabs Pattern)

// components/Tabs.tsx
"use client";
import { useState, useRef } from "react";

interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

export function Tabs({ tabs }: { tabs: Tab[] }) {
  const [active, setActive] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  function handleKeyDown(e: React.KeyboardEvent, index: number) {
    let next = index;

    switch (e.key) {
      case "ArrowRight":
        next = (index + 1) % tabs.length;
        break;
      case "ArrowLeft":
        next = (index - 1 + tabs.length) % tabs.length;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    setActive(next);
    tabRefs.current[next]?.focus();
  }

  return (
    <div>
      {/* Tab list container */}
      <div role="tablist" aria-label="Content sections">
        {tabs.map((tab, i) => (
          <button
            key={tab.id}
            role="tab"
            id={`tab-${tab.id}`}
            aria-controls={`panel-${tab.id}`}
            aria-selected={i === active}
            tabIndex={i === active ? 0 : -1} // roving tabindex
            ref={(el) => {
              tabRefs.current[i] = el;
            }}
            onClick={() => setActive(i)}
            onKeyDown={(e) => handleKeyDown(e, i)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {/* Tab panels */}
      {tabs.map((tab, i) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          // Hidden panels must be hidden from AT and keyboard
          hidden={i !== active}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

3. Focus Trap — Dialog (Manual Wrapping)

// components/Dialog.tsx
"use client";
import { useEffect, useRef } from "react";

const FOCUSABLE =
  'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';

interface DialogProps {
  isOpen: boolean;
  onClose: () => void;
  triggerRef: React.RefObject<HTMLElement>;
  children: React.ReactNode;
  title: string;
}

export function Dialog({
  isOpen,
  onClose,
  triggerRef,
  children,
  title,
}: DialogProps) {
  const dialogRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isOpen || !dialogRef.current) return;

    const els = Array.from(
      dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE),
    );
    els[0]?.focus();

    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") {
        onClose();
        return;
      }
      if (e.key !== "Tab") return;
      if (e.shiftKey && document.activeElement === els[0]) {
        e.preventDefault();
        els[els.length - 1]?.focus();
      } else if (
        !e.shiftKey &&
        document.activeElement === els[els.length - 1]
      ) {
        e.preventDefault();
        els[0]?.focus();
      }
    }

    dialogRef.current.addEventListener("keydown", onKey);
    return () => {
      dialogRef.current?.removeEventListener("keydown", onKey);
      triggerRef.current?.focus(); // return focus on close
    };
  }, [isOpen, onClose, triggerRef]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-heading"
      ref={dialogRef}
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
    >
      <div className="rounded-lg bg-white p-6 shadow-xl">
        <h2 id="dialog-heading">{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

// components/SkipNav.tsx — Server Component, no "use client" needed
export function SkipNav() {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-white focus:px-4 focus:py-2 focus:text-black focus:shadow"
    >
      Skip to main content
    </a>
  );
}
// app/layout.tsx
import { SkipNav } from "@/components/SkipNav";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <SkipNav />
        <nav aria-label="Main">{/* nav items */}</nav>
        {/* tabIndex={-1} allows skip link to focus <main> programmatically */}
        <main id="main-content" tabIndex={-1}>
          {children}
        </main>
      </body>
    </html>
  );
}

5. Disclosure / Dropdown with aria-expanded

// components/NavDropdown.tsx
"use client";
import { useRef, useState } from "react";

interface Item {
  label: string;
  href: string;
}

export function NavDropdown({
  label,
  items,
}: {
  label: string;
  items: Item[];
}) {
  const [open, setOpen] = useState(false);
  const menuRef = useRef<HTMLUListElement>(null);

  function handleButtonKeyDown(e: React.KeyboardEvent) {
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      setOpen(true);
      // Focus first menu item after the menu renders
      setTimeout(() => {
        menuRef.current?.querySelector<HTMLElement>("a")?.focus();
      }, 0);
    }
  }

  function handleMenuKeyDown(e: React.KeyboardEvent) {
    if (e.key === "Escape") {
      setOpen(false);
      // Programmatically return focus to the toggle button
      (e.currentTarget.previousElementSibling as HTMLElement)?.focus();
    }
  }

  return (
    <div style={{ position: "relative" }}>
      <button
        type="button"
        aria-haspopup="menu"
        aria-expanded={open} // tells AT whether the menu is open
        onClick={() => setOpen(!open)}
        onKeyDown={handleButtonKeyDown}
      >
        {label}
      </button>

      {open && (
        <ul
          role="menu"
          ref={menuRef}
          onKeyDown={handleMenuKeyDown}
          style={{ position: "absolute", top: "100%", left: 0 }}
        >
          {items.map((item) => (
            <li key={item.href} role="none">
              <a role="menuitem" href={item.href}>
                {item.label}
              </a>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

6. :focus-visible — Visible Focus Without Mouse Noise

/* globals.css */

/* Remove default focus ring globally */
*:focus {
  outline: none;
}

/* Show a crisp focus indicator ONLY for keyboard navigation */
/* :focus-visible is suppressed for mouse/touch clicks */
*:focus-visible {
  outline: 2px solid #2563eb; /* blue-600 */
  outline-offset: 2px;
  border-radius: 2px;
}

Never use * { outline: none } without a :focus-visible replacement. Removing all focus indicators is a WCAG 2.4.7 failure (Focus Visible) and makes your app completely unusable for keyboard users.


Real-World Use Case

A SaaS dashboard has a top navigation bar with dropdown menus, a data table with sortable column headers, and a modal for editing records. Without intentional keyboard patterns: a screen reader user can't reach the sort buttons (no roving tabindex in the header row), gets trapped outside modals, or loses their page position every time a dropdown closes. Implementing roving tabindex for the column headers (role="columnheader" buttons with arrow-key navigation), a focus trap for the edit modal, and trigger focus restoration on close makes the entire dashboard operable without a mouse.


Common Mistakes / Gotchas

1. Removing focus styles entirely. * { outline: none } without :focus-visible replacement is a WCAG 2.4.7 failure. Keyboard users have no indicator of where focus is. Always replace with :focus-visible styles.

2. Using <div> for interactive elements. A <div onClick={...}> has no role, no keyboard access, and no implicit semantics. Use <button> for actions and <a href> for navigation. If you must use a div, add role, tabIndex={0}, and keydown handlers — but this is almost always more code than using the correct HTML element.

3. Not restoring focus after UI transitions. When a modal closes, a dropdown collapses, or a panel is removed, focus often falls to <body>. Always track the trigger element with a ref and call .focus() on close.

4. Incorrect tab order due to CSS reordering. flex-direction: row-reverse, CSS order, or position: absolute can visually reorder elements while DOM order (and Tab order) stays the same. Visual order and DOM order must match — WCAG 2.1 SC 1.3.2.

5. Omitting aria-expanded, aria-selected, aria-checked. Keyboard interaction without state feedback is incomplete. Screen readers need ARIA state attributes to announce whether a dropdown is open, a tab is selected, or a checkbox is checked. These must be kept in sync with your component state.


Summary

Keyboard navigation requires two distinct layers: basic Tab reachability (use native HTML elements wherever possible) and composite widget patterns (roving tabindex, focus trapping, arrow-key handlers). For any composite widget, consult the ARIA APG for the expected keyboard contract — toolbars and tab lists use arrow keys with roving tabindex; dialogs use Tab-wrapping or inert. Always use :focus-visible rather than removing outlines. Always restore focus to the trigger element when a widget closes. Keep aria-expanded, aria-selected, and aria-pressed in sync with component state so screen readers can announce the current UI state.


Interview Questions

Q1. What is the roving tabindex pattern and why is it used for composite widgets?

Roving tabindex is a pattern for composite widgets (toolbars, tab lists, radio groups, grids) where only one element in the group is in the natural Tab sequence at a time — it has tabIndex={0}. All others have tabIndex={-1}. Arrow keys move focus within the group and transfer the tabIndex={0} to whichever item is now focused. Tab moves focus out of the group entirely to the next focusable element on the page. The rationale: if a toolbar with 8 buttons all had tabIndex={0}, a keyboard user would have to Tab through all 8 buttons every time they passed the toolbar on the way to the next interactive element. Roving tabindex makes the toolbar a single Tab stop — the user enters with Tab, navigates internally with arrow keys, and exits with Tab. This matches the expected interaction model for desktop applications and is specified in the ARIA APG for all composite widget patterns.

Q2. What is the ARIA APG keyboard contract for a tab list, and how does it differ from a toolbar?

According to the ARIA APG, a tab list uses arrow keys to move between tabs AND activates the newly focused tab (automatic activation variant) or requires Enter/Space to activate (manual activation variant). Left/Right arrows navigate; Home focuses the first tab; End focuses the last. This is identical to the toolbar arrow-key contract. The key structural difference: a tab list has role="tablist" on the container, role="tab" on each button, and role="tabpanel" on each panel. Each tab must have aria-controls pointing to its panel's ID, and aria-selected="true" on the active tab and aria-selected="false" on others. Hidden panels use the hidden attribute (not CSS display: none + a hidden prop) so they are removed from both the visual display and the accessibility tree — display: none achieves the same, but hidden is the correct semantic for "this content is not currently relevant."

Q3. What is the difference between :focus and :focus-visible and why should you use :focus-visible for focus indicator styles?

:focus matches whenever an element has focus — whether from keyboard Tab, mouse click, or touch tap. Showing a visible focus ring on mouse click is often considered visually noisy, so many developers historically did * { outline: none }, which also removed rings for keyboard users. :focus-visible is a browser heuristic: it applies only when the browser determines the focus indicator should be visible — primarily when the user is navigating with the keyboard, or when a text input gains focus (since the cursor is always visible there). Mouse clicks on buttons do not trigger :focus-visible. The correct pattern: suppress outline on :focus, apply a clear indicator only on :focus-visible. This gives keyboard users a clear focus ring without the visual noise of outlines on every button click. Never remove focus indicators without a :focus-visible replacement — WCAG 2.4.7 (Focus Visible) requires focus to be visible for keyboard users.

Q4. What ARIA attributes must stay in sync with component state for keyboard-navigable widgets?

Several ARIA state attributes must be kept in sync with JavaScript state: aria-expanded on a trigger button (true when the associated disclosure, dropdown, or subtree is open; false when closed), aria-selected on tab items (true for the active tab; false for others — note: NOT hidden tabs, every tab needs an explicit aria-selected), aria-checked on custom checkboxes and radio buttons (true/false/mixed), aria-pressed on toggle buttons (true when pressed), aria-current on the active page in navigation (aria-current="page"), and aria-disabled for items disabled via JS rather than the HTML disabled attribute. Failing to update these means a keyboard user presses Enter on a "closed" dropdown button, the menu opens visually, but the screen reader still announces aria-expanded="false" — the AT's model of the UI is wrong. React makes this straightforward: these attributes are just props derived from state.

Q5. What is the ARIA role hierarchy for a navigation dropdown menu, and what keyboard interactions are required?

A navigation dropdown uses: a <button> with aria-haspopup="menu" and aria-expanded to toggle, a <ul> with role="menu" for the dropdown container, <li> elements with role="none" (stripping their default list item role), and <a> elements with role="menuitem" for each option. Required keyboard interactions per the APG menu button pattern: Enter or Space or ArrowDown on the button opens the menu and moves focus to the first item; Escape closes the menu and returns focus to the trigger button; ArrowDown/ArrowUp navigate between menu items; Home focuses the first item; End focuses the last. When the menu closes (Escape, selecting an item, or clicking outside), focus must return to the trigger button — otherwise keyboard users lose their position in the page.

Q6. When should you NOT use the roving tabindex pattern?

Roving tabindex is appropriate for composite widgets that function as a single logical unit with internal navigation — toolbars, tab lists, radio groups, listboxes, grids. It should NOT be used for arbitrary groups of related-but-independent links or buttons where each is a separate destination or action. Navigation menus (e.g., a main <nav> bar) should generally use regular Tab stops — each link is an independent destination. Forcing roving tabindex on them means a keyboard user who wants to reach the fourth nav link must Tab into the nav, then press ArrowRight three times — this is non-standard and unexpected. Roving tabindex is also not appropriate for form fields — each input should be a separate Tab stop. The test: if the ARIA APG pattern for your widget specifies arrow-key navigation, use roving tabindex. If your widget is a list of independent links or form controls, use standard Tab order.

On this page