First Menu
In this tutorial, you will build a dropdown menu that opens on click, positions below the trigger, supports Arrow key navigation through its items, and closes on Escape or outside click.
The complete example
Here is the complete working component:
<script setup lang="ts">
import { ref } from "vue";
import {
useClick,
useEscapeKey,
useFloatingNode,
useOutsideClick,
usePosition,
useRovingFocus,
} from "v-float";
const items = ["Profile", "Settings", "Billing", "Sign out"];
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const itemEls = ref<(HTMLElement | null)[]>([]);
const node = useFloatingNode({ anchorEl, floatingEl });
usePosition(node, {
placement: "bottom-start",
middlewares: {
offset: 8,
},
});
useClick(node);
useOutsideClick(node);
useEscapeKey(node);
const { getTabindex } = useRovingFocus(node, {
elementsList: itemEls,
loop: true,
openOnArrowKeyDown: true,
});
function handleSelect(item: string) {
console.log("Selected:", item);
node.open.value = false;
}
</script>
<template>
<button ref="anchorEl" type="button" aria-haspopup="menu" :aria-expanded="node.open.value">
Actions
</button>
<div v-if="node.open.value" ref="floatingEl" role="menu">
<button
v-for="(item, index) in items"
:key="item"
:ref="(el) => (itemEls[index] = el as HTMLElement | null)"
type="button"
role="menuitem"
:tabindex="getTabindex(index)"
@click="handleSelect(item)"
>
{{ item }}
</button>
</div>
</template>
Set up element and item refs
Start by declaring refs for the trigger, the menu panel, and the list of item elements:
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const itemEls = ref<(HTMLElement | null)[]>([]);
anchorElreferences the button that opens the menu.floatingElreferences the floating menu container.itemElsholds an array of DOM references to each rendered menu item button.
Create the floating node
Pass the element refs into useFloatingNode to create the node:
const node = useFloatingNode({ anchorEl, floatingEl });
The returned node coordinates element references and the reactive node.open boolean across all composables.
Position below the button
Pass the node to usePosition to align the menu:
usePosition(node, {
placement: "bottom-start",
middlewares: {
offset: 8,
},
});
placement: "bottom-start"aligns the menu below the trigger button along its start edge.middlewares: { offset: 8 }adds an 8-pixel gap below the button using theoffsetmiddleware.
usePosition applies transform styles directly to floatingEl.
Add click and dismiss interactions
Attach the interaction composables to control when the menu opens and closes:
useClick(node);
useOutsideClick(node);
useEscapeKey(node);
useClickopens and closes the menu when the trigger button is clicked.useOutsideClickcloses the menu when clicking anywhere outside the menu and trigger.useEscapeKeycloses the menu when pressing Escape.
Enable arrow key navigation
Pass the item references into useRovingFocus to manage keyboard navigation:
const { getTabindex } = useRovingFocus(node, {
elementsList: itemEls,
loop: true,
openOnArrowKeyDown: true,
});
useRovingFocus implements the WAI-ARIA roving tabindex pattern:
- Arrow keys: Pressing
ArrowDownorArrowUpmoves DOM focus from one menu item to the next. loop: true: PressingArrowDownon the last item loops focus back to the first item.openOnArrowKeyDown: true: PressingArrowDownwhile focused on the trigger button opens the menu and focuses the first item immediately.getTabindex(index): Returns0for the currently focused item and-1for all other items.
Bind elements in the template
Connect the template refs, accessibility attributes, and selection handler:
<button ref="anchorEl" type="button" aria-haspopup="menu" :aria-expanded="node.open.value">
Actions
</button>
<div v-if="node.open.value" ref="floatingEl" role="menu">
<button
v-for="(item, index) in items"
:key="item"
:ref="(el) => (itemEls[index] = el as HTMLElement | null)"
type="button"
role="menuitem"
:tabindex="getTabindex(index)"
@click="handleSelect(item)"
>
{{ item }}
</button>
</div>
aria-haspopup="menu"and:aria-expanded="node.open.value"announce the menu state to assistive technologies.:ref="(el) => (itemEls[index] = el as HTMLElement | null)"collects each rendered item into theitemElsarray.:tabindex="getTabindex(index)"ensures only one item can receive Tab focus at a time.- Clicking any item triggers
handleSelect, executing your action and closing the menu by settingnode.open.value = false.
What happens at runtime
Here is the lifecycle of the menu in action:
- Initial state: The trigger button is visible.
node.open.valueisfalse, so the menu is not mounted. - Opening: The user clicks the button or presses
ArrowDown.node.open.valuebecomestrue. - Mount and position: Vue renders the menu container and items.
usePositionmeasures both elements and positions the menu below the button with an 8-pixel gap. - Keyboard navigation: The user presses
ArrowDownorArrowUp.useRovingFocusmoves DOM focus through the item list. - Selection: The user clicks an item or presses
Enter. The callback runs and setsnode.open.value = false, unmounting the menu. - Dismissal: If the user clicks outside or presses
Escapeinstead,useOutsideClickoruseEscapeKeycloses the menu.
- Nested submenus: Read Hierarchy to connect parent and child menus with safe cursor corridors.
- Middlewares and collisions: Read Middlewares to automatically flip and shift the menu if space runs out below the trigger.
- Keyboard focus models: Read Keyboard Navigation for an overview of roving tabindex versus active descendant virtualization.
- Roving focus API: Check
useRovingFocusfor the full options and return methods.