Keyboard Navigation
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:
useRovingFocusmoves physical DOM focus between items in standalone composite widgets (menus, tabs, toolbars). Focus lands on the item itself with a single tab stop.useAriaActivedescendantdrives virtual focus for text-input widgets (comboboxes, autocompletes). DOM focus stays pinned on the<input>whilearia-activedescendanthighlights the active option.useTypeaheadhandles character-based search and jumping, buffering keystrokes and cycling through matching items. Forward matches into either focus model throughonMatch.useRoleis a semantic synchronizer. It applies standard ARIA roles and popup states such asaria-expandedandaria-controls; focus-specific states such astabindexandaria-activedescendantstay 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-visiblestyling, 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:
| Key | Orientation | Action |
|---|---|---|
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. |
Home | Any | Moves to the first enabled item. |
End | Any | Moves to the last enabled item. |
PageUp | Any | Moves up by pageSize (default 10) in useRovingFocus and useAriaActivedescendant. |
PageDown | Any | Moves down by pageSize (default 10) in useRovingFocus and useAriaActivedescendant. |
Tab | Any | Passes 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 & Key | Composable & Setting | Action 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
- Learn how to build multi-level menus in Build Nested Menus.
- Read the useRovingFocus API reference.
- Read the useAriaActivedescendant API reference.
- Read the useTypeahead API reference.