Hanzo GUI

RovingFocusGroup

Manage keyboard navigation within a group of focusable elements.

A utility component for managing keyboard focus within a group of elements using the roving tabindex pattern. Enables arrow key navigation between focusable items while maintaining a single tab stop for the group.

Note that this is primarily a web component. On native it renders children without keyboard navigation management.

  • Arrow key navigation between items (respects orientation).
  • Single tab stop for the entire group.
  • Optional looping from last to first item.
  • RTL direction support.
  • Tracks active/current item state.

Installation

RovingFocusGroup is already installed in @hanzo/gui, or you can install it independently:

npm install @hanzogui/roving-focus

Usage

Wrap focusable items with RovingFocusGroup and use RovingFocusGroup.Item for each focusable element:

import { Button, RovingFocusGroup, XStack } from '@hanzo/gui'

export default () => (
  <RovingFocusGroup orientation="horizontal" loop>
    <XStack gap="$2">
      <RovingFocusGroup.Item asChild>
        <Button>First</Button>
      </RovingFocusGroup.Item>
      <RovingFocusGroup.Item asChild>
        <Button>Second</Button>
      </RovingFocusGroup.Item>
      <RovingFocusGroup.Item asChild>
        <Button>Third</Button>
      </RovingFocusGroup.Item>
    </XStack>
  </RovingFocusGroup>
)

How It Works

The roving tabindex pattern allows a group of focusable elements to act as a single tab stop. When the user tabs into the group, focus moves to the currently active item. Arrow keys then navigate between items within the group.

This improves keyboard navigation by:

  • Reducing the number of tab stops on a page
  • Providing intuitive arrow key navigation within related controls
  • Maintaining focus state across the group

Orientation

Set orientation to control which arrow keys navigate:

import { Button, RovingFocusGroup, YStack } from '@hanzo/gui'

export default () => (
  // Up/Down arrows navigate, Left/Right are ignored
  <RovingFocusGroup orientation="vertical">
    <YStack gap="$2">
      <RovingFocusGroup.Item asChild>
        <Button>Option 1</Button>
      </RovingFocusGroup.Item>
      <RovingFocusGroup.Item asChild>
        <Button>Option 2</Button>
      </RovingFocusGroup.Item>
      <RovingFocusGroup.Item asChild>
        <Button>Option 3</Button>
      </RovingFocusGroup.Item>
    </YStack>
  </RovingFocusGroup>
)

Looping Navigation

Enable loop to wrap focus from the last item back to the first:

import { Button, RovingFocusGroup, XStack } from '@hanzo/gui'

export default () => (
  <RovingFocusGroup loop>
    <XStack gap="$2">
      <RovingFocusGroup.Item asChild>
        <Button>First</Button>
      </RovingFocusGroup.Item>
      <RovingFocusGroup.Item asChild>
        <Button>Middle</Button>
      </RovingFocusGroup.Item>
      <RovingFocusGroup.Item asChild>
        <Button>Last</Button>
      </RovingFocusGroup.Item>
      {/* Arrow right from "Last" goes to "First" */}
    </XStack>
  </RovingFocusGroup>
)

Controlled Tab Stop

Control which item is the current tab stop:

import { Button, RovingFocusGroup, XStack } from '@hanzo/gui'
import { useState } from 'react'

export default () => {
  const [currentId, setCurrentId] = useState<string | null>('item-2')

  return (
    <RovingFocusGroup
      currentTabStopId={currentId}
      onCurrentTabStopIdChange={setCurrentId}
    >
      <XStack gap="$2">
        <RovingFocusGroup.Item tabStopId="item-1" asChild>
          <Button>First</Button>
        </RovingFocusGroup.Item>
        <RovingFocusGroup.Item tabStopId="item-2" asChild>
          <Button>Second (default)</Button>
        </RovingFocusGroup.Item>
        <RovingFocusGroup.Item tabStopId="item-3" asChild>
          <Button>Third</Button>
        </RovingFocusGroup.Item>
      </XStack>
    </RovingFocusGroup>
  )
}

Non-Focusable Items

Mark items as non-focusable to skip them during keyboard navigation:

import { Button, RovingFocusGroup, XStack } from '@hanzo/gui'

export default () => (
  <RovingFocusGroup>
    <XStack gap="$2">
      <RovingFocusGroup.Item asChild>
        <Button>Enabled</Button>
      </RovingFocusGroup.Item>
      <RovingFocusGroup.Item focusable={false} asChild>
        <Button disabled>Disabled</Button>
      </RovingFocusGroup.Item>
      <RovingFocusGroup.Item asChild>
        <Button>Enabled</Button>
      </RovingFocusGroup.Item>
    </XStack>
  </RovingFocusGroup>
)

Entry Focus Control

Handle focus when the group first receives focus:

import { Button, RovingFocusGroup, XStack } from '@hanzo/gui'

export default () => (
  <RovingFocusGroup
    onEntryFocus={(event) => {
      // Prevent default focus behavior
      // event.preventDefault()
      console.log('Group received focus')
    }}
  >
    <XStack gap="$2">
      <RovingFocusGroup.Item asChild>
        <Button>First</Button>
      </RovingFocusGroup.Item>
      <RovingFocusGroup.Item asChild>
        <Button>Second</Button>
      </RovingFocusGroup.Item>
    </XStack>
  </RovingFocusGroup>
)

API Reference

RovingFocusGroup

PropTypeDefaultRequired
orientation"horizontal" | "vertical"--
dir"ltr" | "rtl"--
loopbooleanfalse-
currentTabStopIdstring | null--
defaultCurrentTabStopIdstring--
onCurrentTabStopIdChange(tabStopId: string | null) => void--
onEntryFocus(event: Event) => void--
asChildbooleanfalse-

RovingFocusGroup.Item

PropTypeDefaultRequired
tabStopIdstring--
focusablebooleantrue-
activebooleanfalse-
asChildbooleanfalse-

Keyboard Navigation

KeyAction
TabMove focus into/out of the group
Arrow Left/RightMove focus between items (horizontal orientation)
Arrow Up/DownMove focus between items (vertical orientation)
Home / Page UpMove focus to first item
End / Page DownMove focus to last item

Usage in Components

RovingFocusGroup is used internally by several Hanzo GUI components to provide keyboard navigation:

  • Tabs - Navigate between tab triggers
  • RadioGroup - Navigate between radio options
  • ToggleGroup - Navigate between toggle options

RovingFocusGroup is primarily designed for web platforms. On React Native, it renders children without keyboard navigation management since native platforms handle focus differently.

Last updated on

On this page