Appearance
First Tooltip
A tooltip is the smallest floating surface you can build. That's what makes it the right first example — it uses every core VFloat piece without piling on complexity.
By the end of this page you'll 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.
Hover the button
Install
sh
vp add v-floatIf your project doesn't use Vite+, install v-float with your package manager instead.
The Complete Example
Here's the full working code. We'll take it apart after.
vue
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingContext, usePosition, useHover } from "v-float";
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const context = useFloatingContext({ refs: { anchorEl, floatingEl } });
const { styles } = usePosition(context, {
placement: "top",
middleware: { offset: 8 },
});
useHover(context);
</script>
<template>
<button ref="anchorEl" type="button">Save changes</button>
<div v-if="context.state.open.value" ref="floatingEl" role="tooltip" :style="styles">
This button saves your changes.
</div>
</template>That's the whole thing. Three composables, two refs, one template. Let's walk through what each piece does.
Two Refs Connect The DOM
ts
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 are the bridge between the template and the composables.
Bind them in the template with ref="anchorEl" and ref="floatingEl", and VFloat can read their geometry once they render.
The Context Ties Everything Together
ts
const context = useFloatingContext({ refs: { anchorEl, floatingEl } });useFloatingContext creates a shared context object. It holds two things you'll use constantly:
context.refs— the anchor, floating, and arrow element refs. Every other composable reads from here.context.state.open— a boolean ref that tracks whether the surface is currently visible. Interaction composables flip this on and off.
The context doesn't position anything. It doesn't listen for hover or click events. It's just the shared root that every other composable plugs into. Think of it as the wiring harness — nothing happens without it, but it doesn't do the work itself.
Positioning Computes The Coordinates
ts
const { styles } = usePosition(context, {
placement: "top",
middleware: { offset: 8 },
});usePosition reads the anchor and floating element from the context, computes where the floating element should go, and returns styles — a ref you bind directly to the template with :style="styles".
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 same six for left and right.
middleware: { offset: 8 } adds an 8-pixel gap between the anchor and the tooltip. Without it, the tooltip sits flush against the button — technically correct, but visually cramped. offset is the simplest middleware, and the one you'll reach for most often.
Middlewares are small functions that adjust the final position. You'll 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 tooltip, offset alone is enough.
Hover Behavior Is One Line
ts
useHover(context);useHover listens for pointer enter and leave events on the anchor and updates context.state.open automatically. You don't write event handlers. You don't manage timeouts. The composable reads the element refs from the context and writes open state back to it.
The Template Has Three Key Bindings
vue
<button ref="anchorEl" type="button">Save changes</button>
<div v-if="context.state.open.value" ref="floatingEl" role="tooltip" :style="styles">
This button saves your changes.
</div>Three lines do real 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="context.state.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.:style="styles"applies the computed position. This is the output ofusePosition— the coordinates that place the tooltip above the button with an 8-pixel gap.
The role="tooltip" attribute tells assistive technology what the element is. It's not required for VFloat to function, but it matters for accessibility.
What Happens When
Tracing the full lifecycle helps the pieces click:
- The page renders. Both refs are
null— the tooltip isn't in the DOM yet. - The button renders and
anchorElgets a real DOM node. - The user hovers the button.
useHoverdetectspointerenterand callscontext.state.setOpen(true, ...). context.state.open.valuebecomestrue. Thev-ifmounts the tooltip.floatingElgets a real DOM node.usePositionreads both element rects, appliesplacement: "top"andoffset: 8, and writes the result tostyles.- The tooltip appears above the button with the correct gap.
- The pointer leaves.
useHovercallssetOpen(false, ...). Thev-ifunmounts the tooltip.
That loop — hover in, open, position, hover out, close — is the same for every floating surface. The composables change; the context 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 context, Floating Context explains the refs and state groups in detail.