Accessibility

Keyboard Navigation

Add keyboard navigation to floating menus, dropdowns, and nested tree structures.

Predictable keyboard navigation is a core requirement for accessible floating surfaces like menus, listboxes, comboboxes, and submenus.

In VFloat, keyboard navigation is split into a clean separation of concerns:

  1. useRovingFocus moves physical DOM focus between items in standalone composite widgets (menus, tabs, toolbars). Focus lands on the item itself with a single tab stop.
  2. useAriaActivedescendant drives virtual focus for text-input widgets (comboboxes, autocompletes). DOM focus stays pinned on the <input> while aria-activedescendant highlights the active option.
  3. useTypeahead handles character-based search and jumping, buffering keystrokes and cycling through matching items. Forward matches into either focus model through onMatch.
  4. useRole is a semantic synchronizer. It applies standard ARIA roles and popup states such as aria-expanded and aria-controls; focus-specific states such as tabindex and aria-activedescendant stay in your render layer.

Keyboard navigation strategy

We separate keyboard navigation into two distinct patterns based on whether the component requires continuous text input:

  • Virtual Focus (aria-activedescendant): Used exclusively for text-input-driven components (such as Comboboxes, Autocompletes, Searchable Selects). Physical DOM focus remains locked on the <input> to preserve the text cursor, IME composition, and mobile software keyboards, while virtual focus navigates suggestions.
  • Physical Roving Focus (Roving Tabindex): Used for all standalone composite widgets (such as Menus, Tabs, Toolbars, Trees, and non-searchable Listboxes). Physical DOM focus moves directly to each item, providing native :focus-visible styling, built-in scroll alignment, and robust screen reader support.

DOM focus model for menus and action lists

In this model, focus actually shifts into the floating list, and arrow keys move physical DOM focus between list items using a roving tabindex. Only the active item is focusable (tabindex="0"), while the rest are ignored (tabindex="-1").

Composable setup

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

interface MenuItem {
  id: string;
  label: string;
  disabled?: boolean;
}

const items = ref<MenuItem[]>([
  { id: "edit", label: "Edit Item" },
  { id: "duplicate", label: "Duplicate" },
  { id: "archive", label: "Archive Item", disabled: true },
  { id: "delete", label: "Delete" },
]);

const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const itemEls = shallowRef<(HTMLElement | null)[]>([]);

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

// 1. Move physical DOM focus across items
const { activeIndex, getTabindex } = useRovingFocus(node, {
  elementsList: itemEls,
  orientation: "vertical",
  loop: true,
});

// 2. Keep ARIA roles synchronized
useRole(node, {
  role: "menu",
  listRef: itemEls,
  disabledIndices: (idx) => !!items.value[idx]?.disabled,
});
</script>

Template

Render item elements with roving tabindex from getTabindex and bind dynamic active classes:

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

  <ul v-if="node.open.value" ref="floatingEl" role="menu">
    <li
      v-for="(item, index) in items"
      :key="item.id"
      :ref="(el) => (itemEls[index] = el as HTMLElement | null)"
      role="menuitem"
      :aria-disabled="item.disabled"
      :tabindex="getTabindex(index)"
      :class="{ active: activeIndex === index }"
    >
      {{ item.label }}
    </li>
  </ul>
</template>

Virtual focus model for comboboxes and inputs

In this model, DOM focus stays inside a text input or combobox container, allowing the user to keep typing. Arrow keys move a "virtual focus" selection, communicating the active choice to screen readers using aria-activedescendant.

Composable setup

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

interface SearchOption {
  value: string;
  label: string;
}

const options = ref<SearchOption[]>([
  { value: "vue", label: "Vue.js" },
  { value: "react", label: "React" },
  { value: "svelte", label: "Svelte" },
]);

const query = ref("");
const isOpen = ref(false);
const inputEl = ref<HTMLInputElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const itemEls = shallowRef<(HTMLElement | null)[]>([]);

// The input element itself acts as the positioning anchor
const node = useFloatingNode({
  anchorEl: inputEl,
  floatingEl,
  open: isOpen,
});

usePosition(node, {
  placement: "bottom-start",
  middlewares: {
    offset: 4,
    matchWidth: true,
  },
});

useOutsideClick(node);
useEscapeKey(node);

const filteredOptions = computed(() =>
  options.value.filter((o) => o.label.toLowerCase().includes(query.value.toLowerCase())),
);

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

useRole(node, {
  role: "listbox",
  listRef: itemEls,
});
</script>

Template

Bind :id="getItemId(index)" on each option. The aria-activedescendant attribute on the input trigger is automatically synchronized by useAriaActivedescendant:

<template>
  <input
    ref="inputEl"
    v-model="query"
    type="text"
    role="combobox"
    aria-autocomplete="list"
    :aria-expanded="node.open.value"
    @focus="node.open.value = true"
  />

  <ul v-if="node.open.value" ref="floatingEl" role="listbox">
    <li
      v-for="(item, index) in filteredOptions"
      :key="item.value"
      :id="getItemId(index)"
      :ref="(el) => (itemEls[index] = el as HTMLElement | null)"
      role="option"
      :aria-selected="activeIndex === index"
      :class="{ active: activeIndex === index }"
    >
      {{ item.label }}
    </li>
  </ul>
</template>

Keyboard interactions resolved

Here are the key events handled automatically by the focus models:

KeyOrientationAction
ArrowDown"vertical"Moves to the next enabled item.
ArrowUp"vertical"Moves to the previous enabled item.
ArrowRight"horizontal"Moves to the next enabled item (or previous in RTL).
ArrowLeft"horizontal"Moves to the previous enabled item (or next in RTL).
ArrowRight"vertical" (Menu)Fires onEnter; submenu triggers use this to open the child floating node and focus its first item.
ArrowLeft"vertical" (Menu)Fires onExit; submenu panels use this to close the child floating node and return focus to the parent trigger.
HomeAnyMoves to the first enabled item.
EndAnyMoves to the last enabled item.
PageUpAnyMoves up by pageSize (default 10) in useRovingFocus and useAriaActivedescendant.
PageDownAnyMoves down by pageSize (default 10) in useRovingFocus and useAriaActivedescendant.
TabAnyPasses through to document flow. In non-modal useFocusTrap surfaces, closeOnTab closes on exit.

Opening closed surfaces on arrow keys

When keyboard focus is on the anchor trigger or input and the floating surface is closed, arrow keys can initiate disclosure directly:

Pattern & KeyComposable & SettingAction When Closed
ArrowDown (Menu Button)useRovingFocus({ openOnArrowKeyDown: true })Opens floating element and moves physical focus to first item.
ArrowUp (Menu Button)useRovingFocus({ openOnArrowKeyDown: true })Opens floating element and moves physical focus to last item.
ArrowDown (Combobox)useAriaActivedescendant (default true)Opens floating element and highlights first option virtually.
ArrowUp (Combobox)useAriaActivedescendant (default true)Opens floating element and highlights last option virtually.
Alt+ArrowDown (Combobox)useAriaActivedescendant (default true)Opens floating element without moving virtual focus (index: -1).
Alt+ArrowUp (Combobox, open)useAriaActivedescendant (default true)Closes floating element without clearing active selection.

Where to go next

Released under the MIT License. Copyright © 2026