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.

Left
Right
Up
Down

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

  1. The group maintains one item with tabindex="0" (the current tab stop)
  2. All other items have tabindex="-1" (focusable but not in tab order)
  3. Arrow key navigation updates which item is the tab stop
  4. Focus automatically moves to the new tab stop
  5. 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" or tabindex="-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