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.
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
};
};
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.
tabIndex 0; every other item gets -1. Exactly one item is 0 at any moment, before and after every move.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.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: 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.Home/End, type-ahead, and two-dimensional grid movement are extensions — see the solution's "Going further".You will give a set of buttons a single shared Tab stop and wire the arrow keys to move focus between them — the roving-tabindex pattern that sits behind every accessible toolbar, menu bar, and radio group.
A toolbar with ten buttons is, by default, ten Tab stops. Every button is naturally focusable, so a keyboard user has to press Tab ten times just to walk past the toolbar to the next thing on the page. The WAI-ARIA Authoring Practices say a composite widget — a toolbar, a menu, a radio group, a grid — should be a single Tab stop instead: Tab moves into the widget once, the next Tab moves past it entirely, and once you are inside, the arrow keys move between the items.
This is a different job from a focus trap. A trap intercepts Tab so focus cannot leave a container (a modal). Here Tab is left alone to enter and exit in one step; the arrows do the moving within the widget.
The technique is called roving tabindex: at any moment exactly one item carries tabindex="0" and every other item carries tabindex="-1". A -1 element is still focusable by script, but the Tab key skips it — so Tab sees only the single 0. The arrow keys then do two things at once: change which item is the 0, and move real keyboard focus onto it so the two never drift apart.
The obvious version tracks the active index in state and bumps it on every arrow key, setting each item's tabIndex from it:
const { useState } = require('react');
function useRovingTabIndex(options) {
const { count } = options;
const [activeIndex, setActiveIndex] = useState(0);
const getItemProps = (index) => ({
tabIndex: index === activeIndex ? 0 : -1,
onKeyDown: (event) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
setActiveIndex((i) => Math.min(i + 1, count - 1));
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
setActiveIndex((i) => Math.max(i - 1, 0));
}
},
});
return { activeIndex, setActiveIndex, getItemProps };
}
The tabIndex distribution is right, so this looks done — but it is broken in two ways. First, changing tabindex does not move focus. After ArrowRight a different item is the 0, but the browser has left focus on the button you were already on; the user presses the arrow and nothing visibly happens. Second, it answers every arrow key, so a horizontal toolbar also swallows Up and Down — hijacking the page's scroll and fighting the direction a screen reader expects.
const { useState, useRef, useCallback } = require('react');
function useRovingTabIndex(options) {
const { count, orientation = 'horizontal', loop = true } = options;
// Which item owns the single Tab stop. Starts on the first item.
const [activeIndex, setActiveIndex] = useState(0);
// The items' DOM nodes, by index, so the hook can move real focus to one.
const itemRefs = useRef([]);
const getItemProps = useCallback(
(index) => {
// Which arrow keys this orientation listens to.
const horizontal = orientation === 'horizontal' || orientation === 'both';
const vertical = orientation === 'vertical' || orientation === 'both';
return {
// One Tab stop total: the active item is 0, every other item is -1
// (still focusable by script, but skipped by the Tab key).
tabIndex: index === activeIndex ? 0 : -1,
// Record this item's node so we can call .focus() on it later.
ref: (node) => {
itemRefs.current[index] = node;
},
onKeyDown: (event) => {
// The handler fires on the focused item, so `index` is where focus is
// right now. Point at the neighbour the pressed key asks for.
let next;
if (horizontal && event.key === 'ArrowRight') next = index + 1;
else if (horizontal && event.key === 'ArrowLeft') next = index - 1;
else if (vertical && event.key === 'ArrowDown') next = index + 1;
else if (vertical && event.key === 'ArrowUp') next = index - 1;
else return; // not an arrow this orientation owns — let it through
// Past an edge: wrap around when looping, otherwise stay put.
if (next < 0) next = loop ? count - 1 : 0;
else if (next > count - 1) next = loop ? 0 : count - 1;
event.preventDefault(); // we own these keys; don't scroll the page
setActiveIndex(next); // move the Tab stop: next → 0, the rest → -1
itemRefs.current[next]?.focus(); // AND move real focus to follow it
},
};
},
[activeIndex, count, orientation, loop],
);
return { activeIndex, setActiveIndex, getItemProps };
}
module.exports = { useRovingTabIndex };
Two shifts fix the naive version. A ref on each item records its DOM node, so when the active index moves the hook can call .focus() on the new item — the tab stop and real focus move together. And an orientation gate decides up front which arrow keys are even live, so the widget claims only the axis it owns. Notice the handler navigates from index, the item the key fired on, which is exactly where focus is — not from stored state — so a click that moved focus and then an arrow key still step from the right place.
A horizontal toolbar should respond to Left and Right; a vertical menu to Up and Down; a two-dimensional widget to both. Wiring all four keys everywhere is not "extra safety" — it makes the toolbar eat the page's Up/Down scroll and contradicts the axis assistive tech announces. The horizontal and vertical flags computed at the top of getItemProps are the whole gate: an arrow the orientation does not own falls through to return, and the browser handles the key normally.
One detail that surprises people: calling .focus() on an item that currently has tabindex="-1" works fine. tabindex="-1" only removes an element from the Tab sequence; it stays programmatically focusable, which is exactly why the whole pattern holds together.
A horizontal toolbar of three buttons — Bold, Italic, Underline — with the default loop: true:
activeIndex is 0, so Bold gets tabIndex 0 and the other two get -1. Tab from the page lands on Bold, the one Tab stop.index 0; horizontal owns ArrowRight, so next is 1. It calls preventDefault(), setActiveIndex(1) (Italic becomes the 0, Bold drops to -1), then itemRefs.current[1].focus() — focus lands on Italic. Tab order and focus moved together.index is 2, next is 3, which is past the end; loop is true so it wraps to 0. Focus returns to Bold.tabindex 0, so a single Tab leaves the toolbar for the next control on the page — never stepping through the other buttons.tabindex but not focus. The naive trap: the active item becomes the 0, but focus is still on the item you left, so the arrow key appears to do nothing. Always .focus() the new item — keep a ref per item so you can.orientation and return for keys you do not own.preventDefault(). Without it, ArrowDown scrolls the page and moves the roving focus at the same time. Prevent the default once the key is one you handle.tabindex 0. If the inactive items are not set to -1, you still have N Tab stops — the roving part is the -1 on everyone except the active item.activeIndex of 0, the next arrow jumps to the wrong place. Step from the item the key fired on.Home jumps to the first item, End to the last, and menus often add type-ahead — press r to focus the next item whose label starts with "r". Both are extra branches in the same onKeyDown.rowIndex per item for exactly this.aria-activedescendant instead. Where focus must stay on a single input (a combobox listbox), the alternative is to keep DOM focus put and point aria-activedescendant at the "active" option's id — no roving tabindex at all. Prefer it when the items are not themselves focusable controls.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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.
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
};
};
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.
tabIndex 0; every other item gets -1. Exactly one item is 0 at any moment, before and after every move.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.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: 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.Home/End, type-ahead, and two-dimensional grid movement are extensions — see the solution's "Going further".You will give a set of buttons a single shared Tab stop and wire the arrow keys to move focus between them — the roving-tabindex pattern that sits behind every accessible toolbar, menu bar, and radio group.
A toolbar with ten buttons is, by default, ten Tab stops. Every button is naturally focusable, so a keyboard user has to press Tab ten times just to walk past the toolbar to the next thing on the page. The WAI-ARIA Authoring Practices say a composite widget — a toolbar, a menu, a radio group, a grid — should be a single Tab stop instead: Tab moves into the widget once, the next Tab moves past it entirely, and once you are inside, the arrow keys move between the items.
This is a different job from a focus trap. A trap intercepts Tab so focus cannot leave a container (a modal). Here Tab is left alone to enter and exit in one step; the arrows do the moving within the widget.
The technique is called roving tabindex: at any moment exactly one item carries tabindex="0" and every other item carries tabindex="-1". A -1 element is still focusable by script, but the Tab key skips it — so Tab sees only the single 0. The arrow keys then do two things at once: change which item is the 0, and move real keyboard focus onto it so the two never drift apart.
The obvious version tracks the active index in state and bumps it on every arrow key, setting each item's tabIndex from it:
const { useState } = require('react');
function useRovingTabIndex(options) {
const { count } = options;
const [activeIndex, setActiveIndex] = useState(0);
const getItemProps = (index) => ({
tabIndex: index === activeIndex ? 0 : -1,
onKeyDown: (event) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
setActiveIndex((i) => Math.min(i + 1, count - 1));
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
setActiveIndex((i) => Math.max(i - 1, 0));
}
},
});
return { activeIndex, setActiveIndex, getItemProps };
}
The tabIndex distribution is right, so this looks done — but it is broken in two ways. First, changing tabindex does not move focus. After ArrowRight a different item is the 0, but the browser has left focus on the button you were already on; the user presses the arrow and nothing visibly happens. Second, it answers every arrow key, so a horizontal toolbar also swallows Up and Down — hijacking the page's scroll and fighting the direction a screen reader expects.
const { useState, useRef, useCallback } = require('react');
function useRovingTabIndex(options) {
const { count, orientation = 'horizontal', loop = true } = options;
// Which item owns the single Tab stop. Starts on the first item.
const [activeIndex, setActiveIndex] = useState(0);
// The items' DOM nodes, by index, so the hook can move real focus to one.
const itemRefs = useRef([]);
const getItemProps = useCallback(
(index) => {
// Which arrow keys this orientation listens to.
const horizontal = orientation === 'horizontal' || orientation === 'both';
const vertical = orientation === 'vertical' || orientation === 'both';
return {
// One Tab stop total: the active item is 0, every other item is -1
// (still focusable by script, but skipped by the Tab key).
tabIndex: index === activeIndex ? 0 : -1,
// Record this item's node so we can call .focus() on it later.
ref: (node) => {
itemRefs.current[index] = node;
},
onKeyDown: (event) => {
// The handler fires on the focused item, so `index` is where focus is
// right now. Point at the neighbour the pressed key asks for.
let next;
if (horizontal && event.key === 'ArrowRight') next = index + 1;
else if (horizontal && event.key === 'ArrowLeft') next = index - 1;
else if (vertical && event.key === 'ArrowDown') next = index + 1;
else if (vertical && event.key === 'ArrowUp') next = index - 1;
else return; // not an arrow this orientation owns — let it through
// Past an edge: wrap around when looping, otherwise stay put.
if (next < 0) next = loop ? count - 1 : 0;
else if (next > count - 1) next = loop ? 0 : count - 1;
event.preventDefault(); // we own these keys; don't scroll the page
setActiveIndex(next); // move the Tab stop: next → 0, the rest → -1
itemRefs.current[next]?.focus(); // AND move real focus to follow it
},
};
},
[activeIndex, count, orientation, loop],
);
return { activeIndex, setActiveIndex, getItemProps };
}
module.exports = { useRovingTabIndex };
Two shifts fix the naive version. A ref on each item records its DOM node, so when the active index moves the hook can call .focus() on the new item — the tab stop and real focus move together. And an orientation gate decides up front which arrow keys are even live, so the widget claims only the axis it owns. Notice the handler navigates from index, the item the key fired on, which is exactly where focus is — not from stored state — so a click that moved focus and then an arrow key still step from the right place.
A horizontal toolbar should respond to Left and Right; a vertical menu to Up and Down; a two-dimensional widget to both. Wiring all four keys everywhere is not "extra safety" — it makes the toolbar eat the page's Up/Down scroll and contradicts the axis assistive tech announces. The horizontal and vertical flags computed at the top of getItemProps are the whole gate: an arrow the orientation does not own falls through to return, and the browser handles the key normally.
One detail that surprises people: calling .focus() on an item that currently has tabindex="-1" works fine. tabindex="-1" only removes an element from the Tab sequence; it stays programmatically focusable, which is exactly why the whole pattern holds together.
A horizontal toolbar of three buttons — Bold, Italic, Underline — with the default loop: true:
activeIndex is 0, so Bold gets tabIndex 0 and the other two get -1. Tab from the page lands on Bold, the one Tab stop.index 0; horizontal owns ArrowRight, so next is 1. It calls preventDefault(), setActiveIndex(1) (Italic becomes the 0, Bold drops to -1), then itemRefs.current[1].focus() — focus lands on Italic. Tab order and focus moved together.index is 2, next is 3, which is past the end; loop is true so it wraps to 0. Focus returns to Bold.tabindex 0, so a single Tab leaves the toolbar for the next control on the page — never stepping through the other buttons.tabindex but not focus. The naive trap: the active item becomes the 0, but focus is still on the item you left, so the arrow key appears to do nothing. Always .focus() the new item — keep a ref per item so you can.orientation and return for keys you do not own.preventDefault(). Without it, ArrowDown scrolls the page and moves the roving focus at the same time. Prevent the default once the key is one you handle.tabindex 0. If the inactive items are not set to -1, you still have N Tab stops — the roving part is the -1 on everyone except the active item.activeIndex of 0, the next arrow jumps to the wrong place. Step from the item the key fired on.Home jumps to the first item, End to the last, and menus often add type-ahead — press r to focus the next item whose label starts with "r". Both are extra branches in the same onKeyDown.rowIndex per item for exactly this.aria-activedescendant instead. Where focus must stay on a single input (a combobox listbox), the alternative is to keep DOM focus put and point aria-activedescendant at the "active" option's id — no roving tabindex at all. Prefer it when the items are not themselves focusable controls.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.