Keyboard Navigation

useRovingFocus

Manages physical DOM focus across an ordered list using roving tabindex.

useRovingFocus implements the WAI-ARIA roving tabindex pattern. It manages physical DOM focus across an item list, assigning tabindex="0" to the active item and tabindex="-1" to all others. Arrow keys, Home, End, and PageUp/PageDown shift DOM focus directly to target items.

Implements the NavigationTarget protocol for seamless integration with auxiliary composables like useTypeahead.

Type

function useRovingFocus(node: FloatingNode, options: UseRovingFocusOptions): UseRovingFocusReturn;

type RovingEntryFocusMode = "entry-index" | "last-focused";

interface UseRovingFocusOptions {
  elementsList: MaybeRefOrGetter<Array<HTMLElement | null>>;
  containerEl?: MaybeRefOrGetter<HTMLElement | null>;
  activeIndex?: Ref<number>;
  entryIndex?: MaybeRefOrGetter<number | null | undefined>;
  entryFocusMode?: MaybeRefOrGetter<RovingEntryFocusMode>;
  orientation?: MaybeRefOrGetter<"vertical" | "horizontal" | "both">;
  loop?: MaybeRefOrGetter<boolean>;
  pageSize?: MaybeRefOrGetter<number>;
  rtl?: MaybeRefOrGetter<boolean>;
  enabled?: MaybeRefOrGetter<boolean>;
  scrollIntoView?: MaybeRefOrGetter<boolean>;
  focusOnHover?: MaybeRefOrGetter<boolean>;
  focusDisabledElements?: MaybeRefOrGetter<boolean>;
  openOnArrowKeyDown?: boolean | ((event: KeyboardEvent) => boolean);
  isItemDisabled?: (index: number) => boolean;
  onSelect?: (index: number, event: Event) => void;
  onActiveIndexChange?: (index: number) => void;
  onEnter?: (index: number, event: KeyboardEvent) => void;
  onExit?: (index: number, event: KeyboardEvent) => void;
  isKeyHandled?: (event: KeyboardEvent) => boolean;
}

interface UseRovingFocusReturn extends NavigationTarget {
  readonly activeIndex: Readonly<Ref<number>>;
  tabStopIndex: ComputedRef<number>;
  setActiveIndex: (index: number) => void;
  clearActive: () => void;
  reset: () => void;
  focusIndex: (target: NavigationTargetValue, options?: NavigationTargetOptions) => void;
  getTabindex: (index: number) => 0 | -1;
}

Options

NameTypeDefaultNotes
elementsListMaybeRefOrGetter<Array<HTMLElement | null>>RequiredThe list of HTML element references representing navigable elements.
containerElMaybeRefOrGetter<HTMLElement | null>node.refs.floatingElContainer receiving keyboard and pointer events and used for RTL detection.
activeIndexRef<number>undefinedControlled active index ref.
entryIndexMaybeRefOrGetter<number | null | undefined>0Default entry item holding tabindex="0" when idle. Set -1 for menus.
entryFocusModeMaybeRefOrGetter<RovingEntryFocusMode>"entry-index"Whether re-entry restores "entry-index" or "last-focused".
orientationMaybeRefOrGetter<"vertical" | "horizontal" | "both">"vertical"Direction of navigation.
loopMaybeRefOrGetter<boolean>falseWhen true, navigation wraps around at boundaries.
pageSizeMaybeRefOrGetter<number>10Number of items jumped on PageUp and PageDown.
rtlMaybeRefOrGetter<boolean>Auto-detectedWhether layout follows Right-to-Left reading order.
enabledMaybeRefOrGetter<boolean>trueWhen false, keyboard listeners are inactive.
scrollIntoViewMaybeRefOrGetter<boolean>trueWhether focused elements are automatically scrolled into view.
focusOnHoverMaybeRefOrGetter<boolean>falseWhen true, moving the pointer over an item moves DOM focus and the active index to that item (with preventScroll: true). Crucial for menus and submenus.
focusDisabledElementsMaybeRefOrGetter<boolean>falseAllows disabled items to receive focus for APG discoverability.
openOnArrowKeyDownboolean | ((event: KeyboardEvent) => boolean)falseWhen true (or predicate returns true), pressing ArrowDown or ArrowUp on the anchor opens the floating element and moves focus into the list. Static (non-reactive).
isItemDisabled(index: number) => booleanAuto-detectedCustom predicate for disabled state.
onSelect(index: number, event: Event) => voidundefinedCallback fired on Enter or Space.
onActiveIndexChange(index: number) => voidundefinedCallback fired when active item index changes.
onEnter(index: number, event: KeyboardEvent) => voidundefinedCallback fired on ArrowRight (LTR) / ArrowLeft (RTL) for submenu opening.
onExit(index: number, event: KeyboardEvent) => voidundefinedCallback fired on ArrowLeft (LTR) / ArrowRight (RTL) for submenu closing.

Returns

NameTypeNotes
activeIndexReadonly<Ref<number>>Index of the currently focused item (-1 when unfocused / idle).
tabStopIndexComputedRef<number>Index of the element that currently holds tabindex="0".
focusIndex(target: NavigationTargetValue, options?: NavigationTargetOptions) => voidPolymorphic navigation method. Accepts an index, "reset", or directional keywords ("next", "prev", "first", "last", "page-up", "page-down").
setActiveIndex(index: number) => voidSets active index without focusing the DOM element.
clearActive() => voidClears active focus state.
reset() => voidResets activeIndex and focus history back to initial conditions.
getTabindex(index: number) => 0 | -1Returns 0 if index === tabStopIndex, otherwise -1. Bind to :tabindex.

Details

Opening on Arrow Keys

According to the WAI-ARIA APG Menu Button pattern, pressing ArrowDown or ArrowUp while keyboard focus is on the anchor button opens the menu popup and places initial focus on the first or last menu item respectively.

Setting openOnArrowKeyDown: true (or providing a custom predicate function) enables this behavior directly:

  • ArrowDown: Opens the floating element (node.open.value = true) and moves DOM focus to the first enabled item.
  • ArrowUp: Opens the floating element (node.open.value = true) and moves DOM focus to the last enabled item.
  • Dynamic Element Mounting: If the floating list is rendered conditionally via v-if="node.open.value", useRovingFocus stages the entry intent ("first" or "last"), pre-computes tabStopIndex so items mount with tabindex="0", and automatically focuses the target element once mounted in the DOM.
  • Custom Predicate: Pass a callback (event: KeyboardEvent) => boolean to conditionally allow or suppress opening based on modifier keys, target element, or application state.
  • Static Option: Unlike reactive options, openOnArrowKeyDown is static and evaluated once at composable invocation.

Example

<script setup lang="ts">
import { shallowRef } from "vue";
import { useFloatingNode, useRovingFocus } from "v-float";

const items = ["Profile", "Account Settings", "Billing", "Logout"];
const anchorEl = shallowRef<HTMLElement | null>(null);
const floatingEl = shallowRef<HTMLElement | null>(null);
const elementsList = shallowRef<Array<HTMLElement | null>>([]);

const node = useFloatingNode({ anchorEl, floatingEl });

const { activeIndex, getTabindex, focusIndex } = useRovingFocus(node, {
  elementsList,
  orientation: "vertical",
  loop: true,
  openOnArrowKeyDown: true,
});
</script>

<template>
  <button ref="anchorEl" type="button" @click="node.open.value = !node.open.value">Options</button>

  <div v-if="node.open.value" ref="floatingEl" role="menu" class="menu">
    <button
      v-for="(item, idx) in items"
      :key="item"
      :ref="(el) => (elementsList[idx] = el as HTMLElement | null)"
      role="menuitem"
      :tabindex="getTabindex(idx)"
      :class="{ focused: activeIndex === idx }"
      @click="focusIndex(idx)"
    >
      {{ item }}
    </button>
  </div>
</template>

See Also

Released under the MIT License. Copyright © 2026