useRovingFocus

Give a composite widget (toolbar, tab list, radio group, grid) a single tab stop. Arrow keys, Home/End and optional typeahead move focus between its items.

Tab reaches the toolbar once. ←/→ move between the buttons (wrapping at the ends, and swapped under RTL), Home/End jump to the first and last, and Tab leaves the group. useRovingFocus({ count }) returns getItemProps(i) (tabIndex, ref, onKeyDown, onFocus) to spread on each item, plus activeIndex, focusItem and handleKeyDown for items that route keys themselves. Other options: orientation ('vertical', or 'both' with columns for a grid where ↑/↓ move by a row), loop, controlled activeIndex / onActiveChange, isDisabled to skip items, and typeahead: (i) => label to jump to an item by typing its first letters. Keys and DOM focus are web-only; on native the item props are inert apart from tabIndex.

Loading demo…

import { useState } from 'react'; import { Block, IconButton, a11yProps, useRovingFocus } from '@plocks/ui'; const TOOLS = [ { icon: 'bold', label: 'Bold' }, { icon: 'italic', label: 'Italic' }, { icon: 'underline', label: 'Underline' }, { icon: 'strikethrough', label: 'Strikethrough' }, { icon: 'code', label: 'Code' }, ]; export function Demo() { const [active, setActive] = useState<string[]>(['Bold']); const { getItemProps } = useRovingFocus({ count: TOOLS.length }); const toggle = (label: string) => setActive((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label] ); return ( <Block direction="row" gap="xs" p="xs" radius="md" bg="subtle" {...a11yProps({ role: 'toolbar', label: 'Text formatting', orientation: 'horizontal' })} > {TOOLS.map((tool, index) => { const on = active.includes(tool.label); return ( <IconButton key={tool.label} icon={tool.icon} variant={on ? 'filled' : 'ghost'} accessibilityLabel={tool.label} onPress={() => toggle(tool.label)} {...getItemProps(index)} {...a11yProps({ pressed: on })} /> ); })} </Block> ); }