Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Roving focus and keyboard navigation in React

Turtlekit Editorial Team

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.

BeforeAfterSearch invoicesSearch invoicesSend remindersSend remindersDownload PDFsDownload PDFsMark as paidMark as paidArchiveArchiveINV-2041INV-2041
The dashed outline is the toolbar in both; highlighted controls are Tab stops. Before, Tab visits every button on the way to every invoice. After, the toolbar is one stop, the arrow keys move inside it, and the stop stays on whichever button was used last.

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:

The bar’s Tab stop, moment by moment
MomentactiveHolds tabIndex 0
Page loads0Send reminders
Tab in, then the right arrow1Download PDFs
Tab to the list and back1Download PDFs
End, then Archive is taken away3Mark 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:

Roving focus across common widgets
WidgetKeysMoving also
ToolbarLeft and Right, Home, EndNothing: Enter or Space acts
TabsLeft and Right, Home, EndShows the tab’s panel, unless panels are slow to show
Radio groupAll four arrowsChecks the radio
MenuUp and Down, Home, End, a typed letterNothing: 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.

Compound components in React

When the group’s items are parts that share state through their parent.

Accessible ids with useId in React

When the items need ids, as tabs do to name their panels.

Exposing methods with useImperativeHandle in React

When a parent needs to send focus into the group from outside.

Portals in React

When the group is drawn somewhere other than where it belongs.