A finance assistant who works without a mouse selected three overdue invoices and went looking for Send reminders. The bulk actions bar sat between the search box and the table, so every trip from searching to an invoice took her through Send reminders, Download PDFs, Mark as paid and Archive, whether she wanted them or not.
Her screen reader announced the bar as a toolbar, and a toolbar is meant to move with the arrow keys. She pressed them, and nothing happened.
Roving focus, often called a roving tabindex, answers both. Only one item of a group sits in the Tab order at a time, the arrow keys move focus between the items, and the Tab stop moves with it. A toolbar, a tab list or a menu becomes one stop for Tab, however many controls it holds.
Four stops on the way to every invoice
The bar was four buttons in a labelled box:
// Avoid: role="toolbar" without arrow keys, every button a Tab stop
export function BulkActions({
actions,
}: {
actions: readonly Action[];
}) {
return (
<div role="toolbar" aria-label="Selected invoices">
{actions.map((action) => (
<button key={action.id} type="button" onClick={action.run}>
{action.label}
</button>
))}
</div>
);
}Nothing here is broken in the way a test usually notices. Every button works, and Tab reaches each one. The cost is in the trips. Someone moving from the search box to the list pays four extra presses each time, and a bar that grows with each new feature makes the list further away for everyone who doesn’t use a pointer.
The role makes it worse. role="toolbar" tells assistive technology to expect the arrow keys, and the WAI-ARIA toolbar pattern describes exactly how they should move. A role whose keys don’t work promises something the page doesn’t do, which is worse than no role at all.
Tab between things, arrows within one
The keyboard has two kinds of movement. Tab is for getting from one thing on the page to the next; the arrow keys are for moving inside one thing. A group of related controls should be one thing to Tab and many things to the arrows.
The mechanism is a single attribute. The item that holds the stop has tabIndex=0, and every other item has tabIndex=-1, which keeps it focusable by script and out of the Tab order. When an arrow key moves focus, the zero moves with it, so Tab out and back returns to the button that was used last.
Building the bar’s keyboard
The behaviour lives in a small hook, useRovingTabStop, that any group of controls can use. The bar itself only spreads what the hook returns.
Where each key goes
// Where a key sends focus from item `from` of `count`, or null for a
// key the group leaves alone. Right to left, the arrows swap.
function target(
key: string,
from: number,
count: number,
rtl: boolean,
) {
const next = rtl ? "ArrowLeft" : "ArrowRight";
const previous = rtl ? "ArrowRight" : "ArrowLeft";
if (key === next) return (from + 1) % count;
if (key === previous) return (from - 1 + count) % count;
if (key === "Home") return 0;
if (key === "End") return count - 1;
return null;
}The right arrow moves on and the left arrow moves back, wrapping at the ends, and Home and End jump to them. In right-to-left text the next item is to the left, so the arrows swap. Any other key returns null and is left to the browser, which is what keeps Tab, Enter and Space working as people expect.
One stop, remembered
export function useRovingTabStop(count: number) {
const [active, setActive] = useState(0);
// If the remembered item has gone, the last one keeps the Tab stop.
const current = Math.min(active, count - 1);
function onKeyDown(event: KeyboardEvent<HTMLElement>) {
const group = event.currentTarget;
const items = [
...group.querySelectorAll<HTMLElement>("[data-roving]"),
];
const from = items.indexOf(event.target as HTMLElement);
if (from < 0) return;
const rtl = getComputedStyle(group).direction === "rtl";
const to = target(event.key, from, items.length, rtl);
if (to === null) return;
event.preventDefault();
items[to]?.focus();
}
const item = (index: number) => ({
"data-roving": "",
tabIndex: index === current ? 0 : -1,
onFocus: () => setActive(index),
});
return { onKeyDown, item };
}Three details carry the pattern. The items are found in the DOM, so the arrows follow the order people see even when items are wrapped or reordered. Direction is read from the computed style, so the bar mirrors whether the page sets dir on the root or CSS sets it lower down. And onFocus records the item whichever way focus arrived, by arrow or by Tab, and by click where the browser focuses a clicked button, so the stop stays where the person last was.
Follow the bar through a morning’s work:
| Moment | active | Holds tabIndex 0 |
|---|---|---|
| Page loads | 0 | Send reminders |
| Tab in, then the right arrow | 1 | Download PDFs |
| Tab to the list and back | 1 | Download PDFs |
| End, then Archive is taken away | 3 | Mark as paid, the last left |
The last row is why the hook clamps active to the items it has. Without it no button would hold the zero, and Tab would skip the bar entirely.
The finished bar
"use client";
// The bulk actions bar over the invoice list: one stop for Tab, the
// arrow keys between its buttons.
import type { Action } from "./actions";
import { useRovingTabStop } from "./use-roving-tab-stop";
interface BulkActionsProps {
selected: number;
actions: readonly Action[];
}
export function BulkActions({ selected, actions }: BulkActionsProps) {
const roving = useRovingTabStop(actions.length);
return (
<div
role="toolbar"
aria-label={`${selected} selected invoices`}
onKeyDown={roving.onKeyDown}
>
{actions.map((action, index) => (
<button
key={action.id}
type="button"
onClick={action.run}
{...roving.item(index)}
>
{action.label}
</button>
))}
</div>
);
}The bar is one stop for Tab. The arrows move along it, Home and End jump to its ends, and Tab leaves for the table and comes back to the same button. The label says how many invoices the buttons act on, so a screen reader names the toolbar “3 selected invoices” on the way in.
Keys people already know
Roving focus or aria-activedescendant
There is a second way to manage focus in a group. With aria-activedescendant, focus stays on one element and an attribute points at the active item by id. A combobox needs that, because typing has to stay in its input while the arrows move through the options. Everywhere else, roving focus is the simpler choice: real focus moves, so the browser scrolls the item into view, draws its focus ring and tells assistive technology without extra work.
Which keys, for which widget
The pattern is the same everywhere; the keys and what they do depend on the widget, as the WAI-ARIA patterns set out:
| Widget | Keys | Moving also |
|---|---|---|
| Toolbar | Left and Right, Home, End | Nothing: Enter or Space acts |
| Tabs | Left and Right, Home, End | Shows the tab’s panel, unless panels are slow to show |
| Radio group | All four arrows | Checks the radio |
| Menu | Up and Down, Home, End, a typed letter | Nothing: Enter acts |
What assistive technology is told
The role and the keys come as a pair. The role tells a screen reader what kind of group it is entering and how to move inside it, and the label says what the group is for. Because focus really moves, the focus ring is drawn on the button that has it, which WCAG’s focus visible criterion asks for, and nothing has to be announced by hand.
Testing the Tab order
The test renders the bar between a search box and an invoice link, and moves through it the way the finance assistant did:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import type { Action } from "./actions";
import { BulkActions } from "./bulk-actions";
const ACTIONS: Action[] = [
"Send reminders",
"Download PDFs",
"Mark as paid",
"Archive",
].map((label) => ({
id: label,
label,
run: () => {},
}));
const button = (name: string) => screen.getByRole("button", { name });
// The invoice list's search, its bulk actions and its first invoice.
const list = (actions = ACTIONS, dir: "ltr" | "rtl" = "ltr") => (
<div dir={dir}>
<input aria-label="Search invoices" />
<BulkActions selected={3} actions={actions} />
<a href="/invoices/inv-2041">INV-2041</a>
</div>
);
test("is one Tab stop that remembers its button", async () => {
render(list());
await userEvent.click(screen.getByLabelText("Search invoices"));
await userEvent.tab();
expect(button("Send reminders")).toHaveFocus();
await userEvent.keyboard("{ArrowRight}");
expect(button("Download PDFs")).toHaveFocus();
await userEvent.tab();
expect(
screen.getByRole("link", { name: "INV-2041" }),
).toHaveFocus();
await userEvent.tab({ shift: true });
expect(button("Download PDFs")).toHaveFocus();
});
test("Home and End go to the ends, and the arrows wrap", async () => {
render(list());
button("Send reminders").focus();
await userEvent.keyboard("{End}");
expect(button("Archive")).toHaveFocus();
await userEvent.keyboard("{ArrowRight}");
expect(button("Send reminders")).toHaveFocus();
await userEvent.keyboard("{ArrowLeft}{Home}");
expect(button("Send reminders")).toHaveFocus();
});
test("ArrowLeft moves to the next button right to left", async () => {
render(list(ACTIONS, "rtl"));
button("Send reminders").focus();
await userEvent.keyboard("{ArrowLeft}");
expect(button("Download PDFs")).toHaveFocus();
});
test("keeps a Tab stop when the remembered button goes", async () => {
const { rerender } = render(list());
button("Send reminders").focus();
await userEvent.keyboard("{End}");
rerender(list(ACTIONS.slice(0, 3)));
const stops = screen
.getAllByRole("button")
.filter((b) => b.tabIndex === 0);
expect(stops).toEqual([button("Mark as paid")]);
});All four pass. Remove the clamp from the hook and the last one fails: no button keeps the Tab stop.
Server rendering
The tab indexes are in the server’s HTML, zero on the first button and minus one on the rest, so the bar is already one Tab stop before hydration; the arrow keys start working once it hydrates. The hook holds state, so the bar is a Client Component, marked with "use client".
React 18, 19 and the Compiler
Nothing in the hook needs React 19; it runs unchanged on 18. The React Compiler can memoise the key handler and the item props. Without it they are new functions on each render, which doesn’t matter here, because they go to DOM elements rather than to memoised children.
When the browser already roves
A set of native radio buttons with one name is already one Tab stop, and the arrows already move and check. A status filter with All, Draft, Sent and Overdue is a choice of one, so make it radios and write no key handling at all.
And not every row of buttons is a group. Two unrelated buttons, or a list of links in a navigation bar, are each their own thing to Tab to. Giving them a toolbar role to save a keystroke would teach people keys that the page then has to keep for good.
How Turtlekit’s toolbars and tabs move
Turtlekit’s Toolbar gives you the same bar: one stop for Tab, the arrows, Home and End, mirrored right to left, and the button people were on remembered.
// The bulk actions bar with Turtlekit: Toolbar is one stop for Tab,
// moves with the arrow keys, Home and End, mirrored right to left,
// and remembers the button people were on.
import { Button, Toolbar } from "turtlekit";
interface BulkActionsProps {
selected: number;
onRemind: () => void;
onDownload: () => void;
onMarkPaid: () => void;
}
export function BulkActions(props: BulkActionsProps) {
return (
<Toolbar label={`${props.selected} selected invoices`}>
<Button variant="soft" onClick={props.onRemind}>
Send reminders
</Button>
<Button variant="soft" onClick={props.onDownload}>
Download PDFs
</Button>
<Button variant="soft" onClick={props.onMarkPaid}>
Mark as paid
</Button>
</Toolbar>
);
}A field or a select inside the toolbar keeps its own arrow keys. Tabs moves the same way and shows each panel as its tab gets focus, and SegmentedControl is native radios, so the browser does its roving. For a group of your own, the kit exports useRovingFocus, the key handling its widgets use: it takes a selector for the items and leaves their tabIndex to you.
Built on this pattern in Turtlekit: Tabs and Toolbar.
When the keys go astray
Tab skips the whole toolbar
No item holds tabIndex=0. The remembered item has been removed; clamp the index to the items that remain, as the hook does. Keep an unavailable action focusable with aria-disabled rather than disabled, as the toolbar pattern advises, so the stop never lands on a button that can’t take focus.
The page scrolls as focus moves
The arrow key reached the browser as well. Call preventDefault for every key the group handles, and only for those.
The arrows go the wrong way in Arabic or Hebrew
The direction is taken from a prop or assumed. Read it from the group’s computed style at the moment of the key press.
Fewer stops, the same reach
A keyboard user’s time goes into getting from one place to the next, and every extra Tab stop is a small tax on each trip. The finance assistant’s bar took one stop instead of four, moved with the keys its role promised, and returned her to the button she had used last. None of that removed anything from her reach. Roving focus is a way of respecting the order people move through a page: one step per thing, and the arrows for the detail.