Skip to content

useAriaActivedescendant

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

ts
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>;
  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.
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

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

ts
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

ts
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

vue
<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