30% offEnding soon
useRovingTabIndexLoading saved progress…

useRovingTabIndex

Roving tabindex is the accessibility pattern that turns a group of focusable items into a single Tab stop: exactly one item sits in the Tab order at a time, and the arrow keys move focus between them. It is how a toolbar, menu bar, radio group, or grid behaves for a keyboard user — Tab steps onto the widget and then past it, while the arrows navigate inside. Build useRovingTabIndex(options), a hook that hands you the props to spread on each item. Unlike a focus trap, which intercepts Tab so focus cannot leave a container, this leaves Tab alone and uses the arrow keys to move within the widget. See the WAI-ARIA Authoring Practices for the full keyboard contract.

Signature

function useRovingTabIndex(options: {
  count: number;                                     // number of items
  orientation?: 'horizontal' | 'vertical' | 'both'; // default 'horizontal'
  loop?: boolean;                                    // default true
}): {
  activeIndex: number;                               // the current Tab stop, starts at 0
  setActiveIndex: (index: number) => void;           // move it programmatically
  getItemProps: (index: number) => {
    tabIndex: number;                                // 0 for the active item, -1 for the rest
    onKeyDown: (event: React.KeyboardEvent) => void; // arrows move the active item AND focus
    ref: (node: HTMLElement | null) => void;         // lets the hook focus the item it activates
  };
};

Examples

function Toolbar({ items }) {
  const { getItemProps } = useRovingTabIndex({ count: items.length });
  return (
    <div role="toolbar">
      {items.map((label, i) => (
        <button key={label} {...getItemProps(i)}>{label}</button>
      ))}
    </div>
  );
}
// Tab enters the toolbar once, on the active item. ArrowRight moves focus (and
// the single tabindex="0") to the next button; ArrowLeft moves it back.
// count 3, active item is index 0:
getItemProps(0).tabIndex; // 0
getItemProps(1).tabIndex; // -1
getItemProps(2).tabIndex; // -1
// After ArrowRight fires on item 0 (horizontal orientation), item 1 is active:
//   getItemProps(1).tabIndex === 0, and document.activeElement is item 1.

Notes

  • One Tab stop, always. The active item gets tabIndex 0; every other item gets -1. Exactly one item is 0 at any moment, before and after every move.
  • Focus must follow. onKeyDown moves real focus onto the new item, not just the tabIndex. Changing tabindex alone leaves the keyboard user focused on the item they were already on.
  • Orientation gates the keys. horizontal responds to Left/Right, vertical to Up/Down, both to all four. Keys on the other axis are ignored and left to the browser.
  • Loop or clamp. With loop: true (the default), moving past the last item wraps to the first and vice versa; with loop: false, it stops at the ends.
  • activeIndex starts at 0 and setActiveIndex sets it directly, for a controlled starting item.
  • Out of scope. Skipping disabled items, Home/End, type-ahead, and two-dimensional grid movement are extensions — see the solution's "Going further".