Appearance
Build Accessible Tooltips
Let's build a tooltip that feels natural for both pointer and keyboard users.
Tooltips are small, but the interaction details matter. A good tooltip opens from the right triggers, stays out of the way, and does not disappear the moment the pointer crosses the gap between the trigger and the surface.
This guide uses the same core VFloat shape you saw on the first tooltip page, then adds the pieces that make it accessible:
- Hover opens it for pointer users
- Focus opens it for keyboard users
offsetcreates a visible gapsafePolygonkeeps it open when the pointer crosses that gap
The Goal
We want one tooltip that works for two real user paths:
- A mouse user hovers the trigger
- A keyboard user tabs to the trigger
That usually means useFloatingContext, useHover, useFocus, and offset.
Step 1: Build The Shared Context
Start by wiring the anchor, floating element, and placement.
vue
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingContext, usePosition } 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,
},
});
</script>Step 2: Add Hover And Focus Behavior
Now add the interaction layer that matches tooltip expectations.
vue
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingContext, usePosition, useFocus, 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, {
safePolygon: true,
});
useFocus(context);
</script>useHover(context) opens and closes from pointer movement. safePolygon: true measures the rendered anchor and floating element to protect the actual pointer path, including after positioning flips or shifts it. useFocus(context) opens the same tooltip for keyboard users.
Step 3: Render The Tooltip
Render the trigger and tooltip from the same shared state.
vue
<template>
<button ref="anchorEl" type="button" aria-describedby="save-tooltip">Save</button>
<div
v-if="context.state.open.value"
id="save-tooltip"
ref="floatingEl"
role="tooltip"
:style="styles"
>
Save the current draft without publishing it.
</div>
</template>Two accessibility details are worth calling out:
role="tooltip"identifies the surfacearia-describedbyconnects the trigger to the tooltip content
Why safePolygon Matters
Once you add middleware.offset: 8, there is a visible gap between the trigger and the tooltip. If hover closes immediately on pointerleave, the tooltip can disappear while the pointer is still moving naturally toward it.
safePolygon protects that path between the anchor and the floating element.
When To Skip safePolygon
Skip it when:
- The tooltip never needs pointer interaction
- There is no meaningful gap between anchor and tooltip
- The surface should close the moment the pointer leaves
If you want the full reasoning and tradeoffs, read Safe Polygon Gotchas.
Where To Go Next
- Read Build Popovers and Dropdowns if your surface should be click-driven instead.
- Read Focus Models if you want to understand the keyboard side in more depth.
- Read Safe Polygon Gotchas if hover behavior still feels sticky or fragile.