Skip to content

useTypeahead

useTypeahead captures rapid keystrokes typed inside the floating panel to jump directly to matching items in an item list. It buffers typed characters, supports repeated-character cycling, and emits matching indices to drive focus composables.

Type

ts
function useTypeahead(node: FloatingNode, options?: UseTypeaheadOptions): UseTypeaheadReturn;

interface UseTypeaheadOptions {
  items?: MaybeRefOrGetter<readonly (string | null)[]>;
  containerEl?: MaybeRefOrGetter<HTMLElement | null>;
  target?: NavigationTarget;
  activeIndex?: MaybeRefOrGetter<number>;
  onMatch?: (index: number) => void;
  enabled?: MaybeRefOrGetter<boolean>;
  resetMs?: MaybeRefOrGetter<number>;
  ignoreKeys?: MaybeRefOrGetter<readonly string[]>;
  findMatch?: TypeaheadFindMatchFn | null | undefined;
  isItemDisabled?: (index: number) => boolean;
}

type TypeaheadFindMatchFn = (
  items: readonly (string | null)[],
  query: string,
  activeIndex: number,
) => number;

interface UseTypeaheadReturn {
  searchQuery: Readonly<Ref<string>>;
  reset: () => void;
}

Options

NameTypeDefaultNotes
itemsMaybeRefOrGetter<readonly (string | null)[]>[]Array of text labels to match against. null entries are skipped.
containerElMaybeRefOrGetter<HTMLElement | null>Floating panelKeyboard scope for typeahead search. Override for inline widgets whose list lives outside the panel.
targetNavigationTargetundefinedNavigation target (e.g. useRovingFocus or useAriaActivedescendant). Auto-wires activeIndex and onMatch.
activeIndexMaybeRefOrGetter<number>target?.activeIndex ?? -1Currently active index, used as the starting offset when cycling. Never written; forward matches via onMatch.
onMatch(index: number) => void(idx) => target?.focusIndex(idx)Callback invoked with the index of the matched item.
enabledMaybeRefOrGetter<boolean>trueWhether typeahead search is active.
resetMsMaybeRefOrGetter<number>1000Inactivity timeout in milliseconds before clearing the typing buffer.
ignoreKeysMaybeRefOrGetter<readonly string[]>[]Additional keys to ignore during typeahead search.
findMatchTypeaheadFindMatchFnPrefix searchCustom matcher returning the matching item index, or -1. Out-of-range or disabled results count as no match.
isItemDisabled(index: number) => booleanundefinedPredicate for skipping disabled items during matching.

Returns

NameTypeNotes
searchQueryReadonly<Ref<string>>The current buffered search string. Empty when idle.
reset() => voidClears the search buffer immediately and cancels pending timers.

Details

Keyboard Scope Follows the Trigger and the List Container

Typeahead listens on the floating panel, where the ARIA APG places type-ahead for menus, listboxes, trees, and grids, and on the anchor trigger, which stays searchable while the popup is closed so collapsed selects can preselect. Typing on the trigger never changes open state: it only emits onMatch, leaving opening to the trigger's own activation keys. Idle Space and navigation keys pass through untouched on both targets.

Query Buffering and Cycling

  • Multi-character matching: Typing "c" followed quickly by "a" searches for items starting with "ca" (e.g. "Canada").
  • Repeated single-character cycling: Typing "c", "c", "c" in rapid succession cycles through items starting with "c" (cycling from "Cambodia" → "Cameroon" → "Canada"), resuming after the current activeIndex.
  • Space handling: When the buffer is empty, pressing Space preserves normal button or option activation. When the buffer already contains text, Space appends to the search query, allowing multi-word searches (e.g. "san francisco").
  • Failed queries: When a keystroke produces no match, the buffer is retained until the inactivity timeout expires without moving focus, preventing typos from scattering focus across the list.
  • Backspace support: Pressing Backspace trims the active typing buffer and updates the match accordingly.

Pairing with Focus Composables

Pass target to automatically synchronize activeIndex and route matches to focusIndex:

ts
const roving = useRovingFocus(node, { elementsList });

const { searchQuery } = useTypeahead(node, {
  target: roving,
  items: countryNames,
});

Or provide onMatch explicitly if custom interception is required:

ts
const { focusIndex } = useRovingFocus(node, { elementsList });

const { searchQuery } = useTypeahead(node, {
  items: countryNames,
  onMatch: (index) => focusIndex(index),
});

Example

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

const countries = ["Argentina", "Australia", "Belgium", "Brazil", "Canada", "Chile", "China"];
const anchorEl = useTemplateRef<HTMLElement>("anchor");
const floatingEl = useTemplateRef<HTMLElement>("floating");
const elementsList = ref<Array<HTMLElement | null>>([]);
const open = ref(true);

const node = useFloatingNode({ anchorEl, floatingEl, open });
const roving = useRovingFocus(node, { elementsList });

const { searchQuery } = useTypeahead(node, {
  target: roving,
  items: countries,
});
</script>

<template>
  <button ref="anchor">Choose a country</button>
  <div v-if="open" ref="floating" role="listbox" class="listbox" tabindex="-1">
    <div v-if="searchQuery" class="search-badge">Query: {{ searchQuery }}</div>

    <div
      v-for="(country, idx) in countries"
      :key="country"
      :ref="(el) => (elementsList[idx] = el as HTMLElement | null)"
      role="option"
      :tabindex="getTabindex(idx)"
      :class="{ selected: activeIndex === idx }"
      @click="focusIndex(idx)"
    >
      {{ country }}
    </div>
  </div>
</template>

<style>
.listbox {
  width: 220px;
  border: 1px solid #ccc;
  padding: 6px;
  outline: none;
}
.search-badge {
  font-size: 11px;
  color: #666;
  padding-bottom: 4px;
}
.selected {
  background: #eef2ff;
  color: #3b82f6;
}
</style>

See Also