First Tooltip
A tooltip is the smallest floating surface you can build. That makes it the ideal first example because it uses every core VFloat piece without extra complexity.
By the end of this page you will have a tooltip that appears above a button on hover, closes when the pointer leaves, and keeps a small gap between itself and the trigger.
Install
pnpm add v-float
The complete example
Here is the full working code. We will take it apart section by section right after.
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingNode, usePosition, useHover } from "v-float";
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const node = useFloatingNode({ anchorEl, floatingEl });
usePosition(node, {
placement: "top",
middlewares: { offset: 8 },
});
useHover(node);
</script>
<template>
<button ref="anchorEl" type="button">Save changes</button>
<div v-if="node.open.value" ref="floatingEl" role="tooltip">This button saves your changes.</div>
</template>
That is the entire component: three composables, two refs, and one template. Let us walk through what each piece does.
Two refs connect the DOM
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
VFloat needs to know about two DOM elements: the thing the surface is positioned against (the button), and the surface itself (the tooltip). These refs form the bridge between your template and the composables.
Bind them in the template with ref="anchorEl" and ref="floatingEl", and VFloat reads their geometry once they render.
The floating node ties everything together
const node = useFloatingNode({ anchorEl, floatingEl });
useFloatingNode creates a shared FloatingNode object. It is a flat object without a nested state wrapper, holding the pieces you will use constantly:
node.refs: the anchor, floating, and arrow element refs. Every companion composable reads from here.node.open: a mutable boolean ref (Ref<boolean>) that tracks whether the surface is currently visible. Interaction composables update this ref directly, and you can also read or mutate it yourself.
The node does not position elements and does not bind DOM event listeners. It acts as the shared coordinator that every other composable plugs into. Nothing happens without it, but it delegates the actual work to the other composables.
Positioning computes and applies coordinates
usePosition(node, {
placement: "top",
middlewares: { offset: 8 },
});
usePosition reads the anchor and floating element from the node, computes where the floating element should go, and automatically applies the positioning styles directly to the floating DOM element (node.refs.floatingEl). You don't need to manually bind :style in your template.
Two options matter here:
placement: "top" puts the tooltip above the button. VFloat supports all twelve placements: top, top-start, top-end, bottom, bottom-start, bottom-end, and the corresponding six for left and right.
middlewares: { offset: 8 } adds an 8-pixel gap between the anchor and the tooltip. Without it, the tooltip sits flush against the button, which is technically correct but visually cramped. offset is the simplest middleware, and the one you will reach for most often.
Middlewares are small functions that adjust the final position. You will add more as the surface needs to respond to viewport edges (flip, shift), constrain its size (size), or point an arrow back at the anchor (arrow). For a basic tooltip, offset alone is enough.
Hover behavior is one line
useHover(node);
useHover listens for pointer enter and leave events on the anchor and updates node.open automatically. You do not need to write event handlers or manage timeout IDs. The composable reads the element refs from the node and writes open state back to it.
The template has two key bindings
<button ref="anchorEl" type="button">Save changes</button>
<div v-if="node.open.value" ref="floatingEl" role="tooltip">
This button saves your changes.
</div>
Two lines do the structural work:
ref="anchorEl"andref="floatingEl"give VFloat access to the rendered DOM nodes. Without these, the composables have nothing to position and nothing to listen to.v-if="node.open.value"mounts and unmounts the tooltip based on the shared open state. WhenuseHoversets it totrue, the tooltip appears. When it sets it tofalse, the tooltip disappears.
Notice that there is no :style binding on floatingEl. usePosition automatically synchronizes positioning transforms directly onto the element. If you ever need manual control, you can pass { applyStyles: false } to usePosition and bind :style="styles" yourself.
The role="tooltip" attribute tells assistive technology what the element is. It is not required for VFloat to function, but it matters for accessibility.
What happens at runtime
Tracing the full lifecycle clarifies how the pieces fit together:
- The page renders. Both refs are
nullbecause the tooltip is not in the DOM yet. - The button renders and
anchorElreceives a real DOM node. - The user hovers over the button.
useHoverdetectspointerenterand setsnode.open.value = true. node.open.valuebecomestrue. Thev-ifmounts the tooltip.floatingElreceives a real DOM node.usePositionreads both element rects, appliesplacement: "top"andoffset: 8, and applies the positioning styles directly tofloatingEl.- The tooltip appears above the button with the correct gap.
- The pointer leaves.
useHoversetsnode.open.value = false. Thev-ifunmounts the tooltip.
That loop (hover in, open, position, hover out, close) is the same for every floating surface. The interaction composables change; the node and the template bindings stay the same.
Where to go next
This tooltip opens on hover but ignores keyboard users entirely. Build Accessible Tooltips adds focus behavior, safe polygon support, and proper ARIA wiring.
If you want a click-driven surface instead, Build Popovers and Dropdowns swaps useHover for useClick and adds outside-click dismissal.
For a deeper look at the shared node, Floating Node explains the flat node shape (refs, open, hierarchy methods) in detail.