Skip to main content

useRovingFocus

useRovingFocus is a hook that moves focus between controls with arrow keys, such as buttons in a toolbar.

Import

import { useRovingFocus } from '@vortexlabs/vortex';

Other links

Focus a button, then use Left and Right to move between them.
"use client";

import { Button, Stack, Text, useRovingFocus } from "@vortexlabs/vortex";
import React from "react";

export const RovingFocusExample = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  useRovingFocus({ ref, orientation: "horizontal", circular: true });

  return (
    <Stack gap={4}>
      <Text>Focus a button, then use Left and Right to move between them.</Text>
      <div
        ref={ref}
        role="toolbar"
        aria-label="Focus navigation example"
        style={{ display: "flex", gap: "var(--vortex-space-200)", flexWrap: "wrap" }}
      >
        <Button variant="secondary">First</Button>
        <Button variant="secondary">Second</Button>
        <Button variant="secondary">Third</Button>
      </div>
    </Stack>
  );
};

Features

  • Keeps one control in the group’s tab order.
  • Supports horizontal, vertical, and wrapping navigation.

Pass the container ref and navigation options. The hook returns nothing and moves focus only; your controls own their actions and selection.

Orientation and Wrapping

Horizontal mode uses Left and Right; vertical mode uses Up and Down. Omit orientation to accept both pairs, and set circular to wrap from one end to the other.

Last action: None
"use client";

import { Button, Stack, Text, useRovingFocus } from "@vortexlabs/vortex";
import React from "react";

export const VerticalToolbarExample = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [action, setAction] = React.useState("None");
  useRovingFocus({ ref, orientation: "vertical", circular: false });
  return (
    <Stack gap={4}>
      <div
        ref={ref}
        role="toolbar"
        aria-label="Object alignment"
        aria-orientation="vertical"
        style={{ display: "flex", flexDirection: "column", gap: "var(--vortex-space-200)" }}
      >
        <Button variant="secondary" onClick={() => setAction("Align top")}>
          Align top
        </Button>
        <Button variant="secondary" onClick={() => setAction("Align center")}>
          Align center
        </Button>
        <Button variant="secondary" onClick={() => setAction("Align bottom")}>
          Align bottom
        </Button>
      </div>
      <Text size="body-sm-desktop">Last action: {action}</Text>
    </Stack>
  );
};

Horizontal arrows follow the focused group's text direction, including a nested dir="rtl" group.

Initial Tab Stop

Set initial tabIndex values to choose the entry control. The first eligible control whose index is not -1 becomes the tab stop.

Zoom: 100%
"use client";

import { Button, Stack, Text, useRovingFocus } from "@vortexlabs/vortex";
import React from "react";

export const RovingInitialExample = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [zoom, setZoom] = React.useState(100);
  useRovingFocus({ ref, orientation: "horizontal" });
  return (
    <Stack gap={4}>
      <div
        ref={ref}
        role="toolbar"
        aria-label="Zoom presets"
        style={{ display: "flex", gap: "var(--vortex-space-200)" }}
      >
        <Button
          variant="secondary"
          attributes={{ tabIndex: -1, "aria-pressed": zoom === 50 }}
          onClick={() => setZoom(50)}
        >
          50%
        </Button>
        <Button
          variant="secondary"
          attributes={{ tabIndex: 0, "aria-pressed": zoom === 100 }}
          onClick={() => setZoom(100)}
        >
          100%
        </Button>
        <Button
          variant="secondary"
          attributes={{ tabIndex: -1, "aria-pressed": zoom === 200 }}
          onClick={() => setZoom(200)}
        >
          200%
        </Button>
      </div>
      <Text size="body-sm-desktop">Zoom: {zoom}%</Text>
    </Stack>
  );
};

Use controlledTabStop: true to manage indexes yourself. Keep one control at 0 and the others at -1.

Disabling Navigation

Set disabled: true to stop navigation. Existing tab indexes are not restored; reset them yourself when switching to ordinary Tab navigation.

useRovingFocus({ ref, disabled: true });

Accessibility

Key
Description
TabShiftTab
Enter or leave the group through its tab stop.
EnterSpace
Activate the focused button; arrow navigation does not activate it.
Left arrowRight arrow
Move focus when horizontal navigation is enabled.
Up arrowDown arrow
Move focus when vertical navigation is enabled.
HomeEnd
Move focus to the first or last control.
Provide a widget role, accessible label, and selection behavior appropriate to the group.