RovingFocus
Arrow-key navigation with roving tab index for lists and menu-like components.
RovingFocus
The RovingFocus component implements the "roving tab index" pattern, where only one item in a list has tabindex="0" at a time. Users navigate between items with arrow keys, and focus automatically moves to the next item. This is essential for accessible lists, menus, and other multi-item groups.
RovingFocus is the foundation for keyboard navigation in many components: RadioGroup, Select, Menu, Tabs, and Command all use it internally.
Features
- Roving tab index pattern (only one focusable at a time)
- Arrow-key navigation (Up/Down, Left/Right based on orientation)
- Home/End key jumps to first/last
- Optional looping at boundaries
- RTL support
- Active item marking and focus callback
Usage
import { RovingFocusGroup, RovingFocusGroupItem } from '@loke/ui/roving-focus';
export default function Example() {
return (
<RovingFocusGroup orientation="vertical">
<div className="space-y-1">
{['Item 1', 'Item 2', 'Item 3'].map((item, i) => (
<RovingFocusGroupItem key={i} tabStopId={`item-${i}`}>
<button className="w-full px-3 py-2 text-left hover:bg-gray-100 focus:outline-none focus:bg-blue-100">
{item}
</button>
</RovingFocusGroupItem>
))}
</div>
</RovingFocusGroup>
);
}Props
RovingFocusGroup
Prop
Type
RovingFocusGroupItem
Prop
Type
Examples
Horizontal toolbar
Use arrow keys to navigate between toolbar buttons.
Pill-style view selector
Use left/right arrow keys to change view. Only one button is in the tab order at a time.
Selected: Grid
Vertical list
Use up/down arrow keys to navigate this vertical list.
Simple menu
<RovingFocusGroup orientation="vertical" loop>
<div className="border rounded shadow-lg">
{['Edit', 'Copy', 'Paste', 'Delete'].map((action) => (
<RovingFocusGroupItem key={action} tabStopId={action}>
<button className="w-full px-4 py-2 text-left hover:bg-gray-100 focus:bg-blue-100">
{action}
</button>
</RovingFocusGroupItem>
))}
</div>
</RovingFocusGroup>With render function for dynamic styling
<RovingFocusGroup orientation="horizontal">
<div className="flex gap-2">
{['Option A', 'Option B', 'Option C'].map((opt) => (
<RovingFocusGroupItem key={opt} tabStopId={opt}>
{({ isCurrentTabStop }) => (
<button
className={isCurrentTabStop ? 'bg-blue-600 text-white' : 'bg-gray-200'}
style={{ padding: '8px 16px', borderRadius: '4px' }}
>
{opt}
</button>
)}
</RovingFocusGroupItem>
))}
</div>
</RovingFocusGroup>Keyboard Navigation
- Arrow Up/Down (vertical) or Arrow Left/Right (horizontal): Move focus to adjacent items
- Home: Jump to first focusable item
- End: Jump to last focusable item
- Tab: Exit the group entirely (moves to next focusable outside)
- Shift+Tab: Exit the group and move to previous focusable
How it works
- The group maintains one item with
tabindex="0"(the current tab stop) - All other items have
tabindex="-1"(focusable but not in tab order) - Arrow key navigation updates which item is the tab stop
- Focus automatically moves to the new tab stop
- Only one item is in the natural tab order at any time
Accessibility
- Group has
role="none"or semantic role depending on context - Items have
tabindex="0"ortabindex="-1"as appropriate - Items support
aria-disabled,aria-selected, and other ARIA attributes - Orientation is announced via
aria-orientation
Best practices
- Set appropriate orientation (horizontal for toolbars, vertical for menus/lists)
- Enable looping for cyclic lists (calendars, carousels); disable for open-ended lists
- Always provide visual feedback for the active/focused item
- Combine with FocusScope for modals to prevent focus escape
- Test keyboard navigation with screen readers to ensure announcements work correctly