Skip to content
Accessibility & inclusive designLesson 3 of 7Intermediate16 min

Keyboard navigation

Every interaction must work from the keyboard alone. Learn tab order, the roving tabindex pattern for composite widgets, and the keys users expect.

Kept on this device only.

In one line: if a thing can be clicked, it must also be reachable and operable from the keyboard — and a composite widget like a toolbar should be one tab stop whose contents you move through with the arrow keys, not a wall of five.

What it is

Keyboard navigation is the ability to reach and operate every interactive control using the keyboard alone — no pointer required. Native elements (<a href>, <button>, <input>, <select>) are focusable and operable out of the box: they sit in the tab order, take focus, and respond to Enter or Space for free.

The default tab order follows DOM order — the sequence in the markup, not the visual layout you painted with CSS. Custom widgets built from <div>s have none of this; you have to add it back deliberately with tabindex, key handlers, and the right ARIA role. The cheapest way to be keyboard-accessible is to reach for a real native element first and only build a custom widget when nothing native fits.

Why it matters

A large group of people never touch a mouse. Keyboard-only users navigate by Tab and arrow keys. Screen-reader users drive everything through the keyboard. People using switch devices, sip-and-puff controls, or voice software all ultimately map to keyboard events. And power users simply move faster on the keys.

This is not a nicety — it is WCAG 2.1.1 (Keyboard), a Level A success criterion: all functionality must be operable through a keyboard interface. A control you can only click is, for these users, a control that does not exist. Get the keyboard right and you have also covered most assistive-tech paths at once.

See it

Live demo
Tweak it3
Tab into the toolbar, then use the arrow keys. Roving tabindex makes it one tab stop, not five.

Tab into the toolbar, then use the arrow keys. In roving mode the whole toolbar is a single tab stop and Left/Right (plus Home/End) move focus between the buttons. Switch to naive mode and Tab again: now every button is its own stop, so you press Tab five times to cross one widget. Compare how each feels.

How it works

Three ideas do most of the work.

Native focus order. Focusable elements are visited in DOM order when you press Tab. Keep your DOM order matching your visual order so the focus path is predictable; reordering with CSS (order, flex-direction: row-reverse, absolute positioning) can quietly desync the two.

tabindex. tabindex="0" puts an element in the natural tab order at its DOM position. tabindex="-1" makes an element programmatically focusable (via .focus()) but skips it during Tab. Avoid positive tabindex values — they jump the element to the front of the order and create a tab sequence nobody can maintain.

The roving tabindex pattern. For a composite widget (toolbar, menu, tablist, radio group) you want the group to be one tab stop, with the arrow keys moving within it. You do that by giving exactly one child tabIndex={0} and every other child tabIndex={-1}; an onKeyDown handler updates which child is the "active" one, sets the new tabIndex, and calls .focus() on it. Tab moves you past the whole widget; arrows move you through its members.

Which keys a widget should support comes from the ARIA Authoring Practices Guide (APG): a toolbar uses Left/Right and Home/End, a vertical menu uses Up/Down, a tablist uses Left/Right, and so on. Each pattern has a documented keyboard contract — follow it so users get the behavior they already expect.

Build it

A minimal roving-tabindex toolbar: one tab stop, arrow keys move focus, Home/End jump to the ends.

tsx
Toolbar.tsx — roving tabindex with refs and arrow-key focus movement
"use client";
 
import { useRef, useState } from "react";
 
const LABELS = ["Bold", "Italic", "Underline", "Link", "Code"];
 
export function Toolbar() {
  const [activeIndex, setActiveIndex] = useState(0);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
 
  function focusIndex(index: number) {
    setActiveIndex(index);
    const next = refs.current[index];
    if (next) next.focus(); // guard the indexed access
  }
 
  function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    const last = LABELS.length - 1;
    if (event.key === "ArrowRight") {
      event.preventDefault();
      focusIndex(activeIndex === last ? 0 : activeIndex + 1);
    } else if (event.key === "ArrowLeft") {
      event.preventDefault();
      focusIndex(activeIndex === 0 ? last : activeIndex - 1);
    } else if (event.key === "Home") {
      event.preventDefault();
      focusIndex(0);
    } else if (event.key === "End") {
      event.preventDefault();
      focusIndex(last);
    }
    // Enter/Space: these are real <button>s, so the browser activates them.
  }
 
  return (
    <div role="toolbar" aria-label="Text formatting" onKeyDown={onKeyDown}>
      {LABELS.map((label, index) => (
        <button
          key={label}
          type="button"
          ref={(node) => {
            refs.current[index] = node;
          }}
          tabIndex={index === activeIndex ? 0 : -1}
          onFocus={() => setActiveIndex(index)}
        >
          {label}
        </button>
      ))}
    </div>
  );
}

Make it yours

Use the controls beside the demo above to change tab behavior, toolbar orientation, and wrap at ends — each change updates the example live.

Experiment in the playground
  • Flip mode to naive and count your Tab presses to cross the widget, then switch back to roving — the difference is the whole point of the pattern.
  • In roving mode, hold focus on a button and press Home then End: focus should jump to the first and last controls without leaving the toolbar.
  • Try tabbing out of the toolbar and back in — focus should return to the button you last used, because its tabIndex is still 0.

Reproduce it with an LLM

Reproduce it with an LLM

You are a senior front-end engineer who follows the ARIA Authoring Practices. Build a horizontal toolbar of icon buttons (bold, italic, underline, link) that is fully keyboard-operable using the roving tabindex pattern: the toolbar is a single Tab stop (only the active button has tabindex=0, the rest tabindex=-1), Arrow Left/Right move focus between buttons (wrapping at the ends), Home/End jump to the first/last, and Enter/Space activate. Give it role=toolbar with an aria-label, a clearly visible :focus-visible style, and ensure focus order matches visual order. Disable any movement animation under prefers-reduced-motion. Return only the component code with imports.

Pitfalls & accessibility

  • Don't trap focus by accident: handlers that preventDefault() on Tab, or modals with no escape, leave keyboard users stuck. Only manage the keys your pattern owns (arrows, Home/End) and let Tab keep doing its job.

Further reading