Keyboard Navigation

useAriaActivedescendant

Manages virtual focus for comboboxes and listboxes using aria-activedescendant.

useAriaActivedescendant manages virtual focus for comboboxes, autocompletes, and searchable pickers. DOM focus remains pinned to the <input> element to preserve caret navigation, IME composition, and virtual mobile keyboards while arrow keys navigate suggestions.

useAriaActivedescendant automatically synchronizes the aria-activedescendant DOM attribute on the anchor element, scrolls active items into view, resets when the floating surface closes, and implements the NavigationTarget protocol.

Type

function useAriaActivedescendant(
  node: FloatingNode,
  options?: UseAriaActivedescendantOptions,
): UseAriaActivedescendantReturn;

interface UseAriaActivedescendantOptions {
  targetEl?: MaybeRefOrGetter<HTMLElement | null>;
  containerEl?: MaybeRefOrGetter<HTMLElement | null>;
  itemCount?: MaybeRefOrGetter<number>;
  elementsList?: MaybeRefOrGetter<Array<HTMLElement | null>>;
  activeIndex?: Ref<number>;
  defaultIndex?: number;
  idPrefix?: string;
  getItemId?: (index: number, key?: string | number) => string;
  getItemKey?: (index: number) => string | number;
  orientation?: MaybeRefOrGetter<"vertical" | "horizontal" | "both">;
  loop?: MaybeRefOrGetter<boolean>;
  pageSize?: MaybeRefOrGetter<number>;
  rtl?: MaybeRefOrGetter<boolean>;
  enabled?: MaybeRefOrGetter<boolean>;
  scrollIntoView?: MaybeRefOrGetter<boolean>;
  editable?: MaybeRefOrGetter<boolean | "auto">;
  preventPointerDown?: MaybeRefOrGetter<boolean>;
  focusOnHover?: MaybeRefOrGetter<boolean>;
  clearOnPointerLeave?: MaybeRefOrGetter<boolean>;
  resetOnBlur?: MaybeRefOrGetter<boolean>;
  focusDisabledElements?: MaybeRefOrGetter<boolean>;
  openOnArrowKeyDown?: boolean | ((event: KeyboardEvent) => boolean);
  isItemDisabled?: (index: number) => boolean;
  virtualizer?: VirtualizerAdapter;
  onSelect?: (index: number, event: Event) => void;
  onActiveIndexChange?: (index: number) => void;
  isKeyHandled?: (event: KeyboardEvent) => boolean;
}

interface UseAriaActivedescendantReturn extends NavigationTarget {
  readonly activeIndex: Readonly<Ref<number>>;
  activeId: ComputedRef<string | undefined>;
  focusIndex: (target: NavigationTargetValue, options?: NavigationTargetOptions) => void;
  setActiveIndex: (index: number) => void;
  clearActive: () => void;
  scrollToActive: () => void;
  getItemId: (index: number, key?: string | number) => string;
}

Options

NameTypeDefaultNotes
targetElMaybeRefOrGetter<HTMLElement | null>node.refs.anchorElTarget element holding physical DOM focus and receiving aria-activedescendant.
containerElMaybeRefOrGetter<HTMLElement | null>node.refs.floatingElContainer element holding the items. Used for bounded scroll calculations.
elementsListMaybeRefOrGetter<Array<HTMLElement | null>>undefinedList of element references for static or dynamic DOM lists.
itemCountMaybeRefOrGetter<number>Inferred / 0Total number of items when using virtualized lists.
activeIndexRef<number>undefinedOptional controlled active index ref.
defaultIndexnumber-1Initial active index in uncontrolled mode (-1 = none).
idPrefixstringAuto useId()Base prefix used for generating descendant element IDs.
getItemId(index: number, key?: string | number) => stringBuilt-in patternCustom function resolving the DOM element ID for an item.
getItemKey(index: number) => string | numberundefinedKey extractor for stable identities in virtualized lists.
orientationMaybeRefOrGetter<"vertical" | "horizontal" | "both">"vertical"Navigation axis.
loopMaybeRefOrGetter<boolean>falseWhen true, arrow keys wrap around at boundaries.
pageSizeMaybeRefOrGetter<number>10Number of items jumped on PageUp and PageDown.
rtlMaybeRefOrGetter<boolean>Auto-detectedRight-to-Left reading order flag.
enabledMaybeRefOrGetter<boolean>trueWhen false, keyboard handlers are inactive.
scrollIntoViewMaybeRefOrGetter<boolean>trueWhether active items are automatically scrolled into view.
editableMaybeRefOrGetter<boolean | "auto">"auto"Preserves Space typing and Home/End caret navigation when target is editable.
preventPointerDownMaybeRefOrGetter<boolean>truePrevents pointerdown default on non-interactive item surfaces to retain input focus.
focusOnHoverMaybeRefOrGetter<boolean>falseActivates item highlight on pointermove.
clearOnPointerLeaveMaybeRefOrGetter<boolean>falseClears highlight when pointer leaves container.
resetOnBlurMaybeRefOrGetter<boolean>falseResets highlight when target input loses focus.
focusDisabledElementsMaybeRefOrGetter<boolean>falseAllows virtual highlighting of disabled items for APG discoverability.
openOnArrowKeyDownboolean | ((event: KeyboardEvent) => boolean)trueWhether pressing arrow keys on the target/anchor opens the floating element when closed. Static (non-reactive).
isItemDisabled(index: number) => booleanAuto-detectedCustom predicate for disabled items.
virtualizerVirtualizerAdapterundefinedVirtual scroller bridge for large lists.
onSelect(index: number, event: Event) => voidundefinedCallback fired on Enter or Space.
onActiveIndexChange(index: number) => voidundefinedCallback fired on active index change.

Returns

NameTypeNotes
activeIndexReadonly<Ref<number>>Current highlighted index, or -1 if none is active.
activeIdComputedRef<string | undefined>DOM ID of the currently active descendant, or undefined when inactive.
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) => voidImperatively sets active index and scrolls into view.
clearActive() => voidClears active descendant (sets index to -1).
scrollToActive() => voidImperatively scrolls the current active item into view.
getItemId(index: number, key?: string | number) => stringResolves the DOM element ID for the item at index (with optional key).

Details

Opening on Arrow Keys

In comboboxes and searchable dropdowns, users expect arrow keys on the <input> element to open the suggestion popup and highlight an option without requiring manual mouse clicks or tab transitions.

useAriaActivedescendant enables openOnArrowKeyDown: true by default, implementing the WAI-ARIA APG Combobox pattern:

  • When Closed:
    • ArrowDown: Opens the popup (node.open.value = true) and highlights the first enabled option.
    • ArrowUp: Opens the popup (node.open.value = true) and highlights the last enabled option.
    • Alt+ArrowDown: Opens the popup without moving virtual focus (activeIndex remains -1), matching standard combobox disclosure behavior.
  • When Open:
    • Alt+ArrowUp: Closes the popup (node.open.value = false) without clearing the active selection.
  • Opting Out: Pass openOnArrowKeyDown: false if your combobox should only open on text typing or clicking a disclosure icon.
  • Custom Predicate: Pass a callback (event: KeyboardEvent) => boolean to restrict keyboard opening based on modifier keys or application state.
  • Static Option: Like useRovingFocus, openOnArrowKeyDown is static and evaluated once at composable invocation.

Virtual Focus vs Physical Focus

In a combobox, the user must be able to type in the <input> while simultaneously browsing suggestions:

  • Physical focus (useRovingFocus) moves the browser's cursor out of the <input>, which interrupts text entry and closes mobile virtual keyboards.
  • Virtual focus (useAriaActivedescendant) keeps browser focus pinned to the <input>. As the user presses Arrow Down, the input's aria-activedescendant attribute automatically synchronizes to reference the active item's DOM ID. Screen readers announce the active item, and VFloat scrolls the highlighted element into view.

Virtual Scroller Integration

For lists containing thousands of items, DOM rendering must be virtualized. VFloat exports two adapters that bridge virtualizer engines directly with useAriaActivedescendant:

1. @tanstack/vue-virtual Adapter

import { useVirtualizer } from "@tanstack/vue-virtual";
import { createTanStackVirtualAdapter, useAriaActivedescendant } from "v-float";

const rowVirtualizer = useVirtualizer({
  count: items.length,
  getScrollElement: () => scrollParentEl.value,
  estimateSize: () => 35,
});

const adapter = createTanStackVirtualAdapter(rowVirtualizer);

const { activeIndex, getItemId } = useAriaActivedescendant(node, {
  virtualizer: adapter,
  getItemKey: (idx) => items[idx].id,
});

2. Custom Virtualizer Adapter

import { createCustomVirtualAdapter, useAriaActivedescendant } from "v-float";

const adapter = createCustomVirtualAdapter({
  scrollToIndex: (index, options) => myCustomScroller.scrollTo(index, options),
  count: () => items.value.length,
  isIndexRendered: (index) => myCustomScroller.isRendered(index),
});

Example

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

const anchorEl = shallowRef<HTMLInputElement | null>(null);
const floatingEl = shallowRef<HTMLElement | null>(null);
const elementsList = shallowRef<Array<HTMLElement | null>>([]);
const query = shallowRef("");

const allItems = ["Vue.js", "React", "Svelte", "Solid", "Angular", "Ember"];
const filtered = computed(() =>
  allItems.filter((item) => item.toLowerCase().includes(query.value.toLowerCase())),
);

const node = useFloatingNode({ anchorEl, floatingEl });
usePosition(node, { placement: "bottom-start" });

const { activeIndex, getItemId } = useAriaActivedescendant(node, {
  elementsList,
  onSelect: (index) => {
    query.value = filtered.value[index]!;
    node.open.value = false;
  },
});

function onInput() {
  if (!node.open.value) node.open.value = true;
}
</script>

<template>
  <div class="combobox-wrapper">
    <input
      ref="anchorEl"
      v-model="query"
      role="combobox"
      aria-autocomplete="list"
      :aria-expanded="node.open.value"
      placeholder="Type a framework..."
      @input="onInput"
      @focus="node.open.value = true"
    />

    <ul v-if="node.open.value && filtered.length" ref="floatingEl" role="listbox" class="listbox">
      <li
        v-for="(item, idx) in filtered"
        :id="getItemId(idx)"
        :key="item"
        :ref="(el) => (elementsList[idx] = el as HTMLElement | null)"
        role="option"
        :aria-selected="activeIndex === idx"
        :data-active="activeIndex === idx ? '' : undefined"
        :class="{ highlighted: activeIndex === idx }"
      >
        {{ item }}
      </li>
    </ul>
  </div>
</template>

See Also

Released under the MIT License. Copyright © 2026