Animated Tooltip
Nuxt UI's UTooltip with its fade replaced by a spring-driven entrance.
Positioning, portalling, delay and focus behaviour still come from the
underlying tooltip; the only thing this component adds is the motion — a spring
that scales the content up from 0.95 and settles it as it appears.
The direction of that entrance is derived from content.side. A tooltip opening
below its trigger starts offset four pixels upward and grows into place; one
opening above starts offset downward. The sign is inverted so the content always
arrives from the side it is about to occupy, whichever side that is.
Text and keyboard shortcuts still work exactly as they do upstream, including
the kbds shortcut row. The content slot replaces both when you need richer
markup.
Usage
Put a single focusable trigger in the default slot and describe it with text
or kbds; use the content slot when the tooltip needs more than a line.
Tune the feel with springStiffness and springDamping — the defaults are
snappier and bouncier than Nuxt UI's own fade.
<template>
<div class="flex w-full max-w-lg flex-col items-center gap-6">
<div class="flex flex-wrap items-center justify-center gap-3">
<MAnimatedTooltip
text="Copied to clipboard"
:delay-duration="200"
>
<UButton
icon="i-lucide-copy"
label="Copy link"
color="neutral"
variant="outline"
/>
</MAnimatedTooltip>
<MAnimatedTooltip
text="Save this build"
:kbds="['⌘', 'S']"
>
<UButton
icon="i-lucide-save"
label="Save"
color="neutral"
variant="outline"
/>
</MAnimatedTooltip>
<MAnimatedTooltip
:delay-duration="200"
:disabled="disabled"
>
<UButton
icon="i-lucide-rocket"
label="Deploy"
/>
<template #content>
<div class="max-w-56 space-y-1">
<p class="font-medium text-white">
Deploy to production
</p>
<p class="text-white/70">
Builds on the edge, then swaps traffic once the health check passes.
</p>
</div>
</template>
</MAnimatedTooltip>
</div>
<USwitch
v-model="disabled"
label="Disable the deploy tooltip"
/>
</div>
</template>
<script setup lang="ts">
const disabled = ref(false)
</script>
Placement
Tooltips open below their trigger by default. Pass content.side to place them
elsewhere — the bubble still animates in from whichever edge it lands on, and
arrow adds the pointer.
The entrance is offset from the side it opens on, so the bubble always scales in from the edge it appears beside.
<template>
<div class="flex w-full max-w-lg flex-col items-center gap-6">
<div class="grid w-full grid-cols-2 gap-3 sm:grid-cols-4">
<MAnimatedTooltip
v-for="side in sides"
:key="side"
:text="`Opens on the ${side}`"
:content="{ side, sideOffset: 8, collisionPadding: 8 }"
:delay-duration="150"
arrow
>
<UButton
:label="side"
color="neutral"
variant="outline"
class="w-full justify-center capitalize"
/>
</MAnimatedTooltip>
</div>
<p class="text-xs text-dimmed">
The entrance is offset from the side it opens on, so the bubble always scales in from the edge it appears beside.
</p>
</div>
</template>
<script setup lang="ts">
const sides = ['top', 'right', 'bottom', 'left'] as const
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | - | The plain-text label. Rendered inside a data-slot="text" span with the
upstream tooltip theme classes, unless the content slot overrides it. |
kbds | KbdProps['value'][] | KbdProps[] | - | Keyboard hints rendered as a UKbd row after the text. Entries may be plain
strings or full KbdProps objects, exactly as upstream; the row only appears
when the array is non-empty. |
content | { side?, sideOffset?, collisionPadding?, [key: string]: any } | { side: 'bottom', sideOffset: 8, collisionPadding: 8 } | Positioning options, forwarded to UTooltip's content. Two things depend on
it: collision padding is why the bubble never touches the trigger's edge, and
side decides which way the entrance is offset from. Defaults to a bottom
bubble 8px away with 8px of collision padding. |
arrow | boolean | object | false | Forwarded to UTooltip, so the arrow is the upstream one — it renders inside
the content surface alongside the motion wrapper rather than being animated by
this component. |
delayDuration | number | 700 | Milliseconds to wait after hover or focus before the tooltip appears. The upstream default is deliberately long; anything below roughly 200ms starts to feel twitchy. |
disabled | boolean | false | Turns the tooltip off entirely, without removing the trigger. |
open | boolean | undefined | Controlled open state. update:open fires when it changes, so
v-model:open works. Leave it undefined for the uncontrolled hover/focus
behaviour. |
defaultOpen | number | boolean | undefined | Uncontrolled initial open state, forwarded to UTooltip. |
portal | string | boolean | HTMLElement | true | Where the bubble is mounted. Keep it true unless you need the tooltip to be
clipped to an ancestor's stacking context. |
ui | Record<string, any> | undefined | The upstream tooltip theme overrides, forwarded untouched. The text and
kbds sub-slots read ui.text / ui.kbds from here when rendering their
fallbacks. |
springStiffness | number | 500 | Spring stiffness for the enter. Higher is faster and more abrupt. |
springDamping | number | 20 | Spring damping for the enter. The default ratio against springStiffness
gives a small, tight overshoot. |
Events
| Event | Payload | Description |
|---|---|---|
update:open | (value: boolean) => void | Re-emitted from the underlying tooltip's own update:open, so hover, focus,
Escape and a controlled open all report back through one event. |
Slots
| Slot | Props | Description |
|---|---|---|
default | - | The trigger. It is rendered with as-child upstream, so a single focusable
element is what you want — a bare <span> will not receive focus and the
tooltip will be keyboard-unreachable. |
content | - | Replaces the whole content surface, including the built-in text and kbds
spans. This is where rich markup goes; there is no scoped slot data beyond
what upstream passes through. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.animatedTooltip in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | |
content | - | will-change-transform |
Accessibility
The component adds no ARIA of its own — the animated wrapper is a plain
motion.div carrying will-change-transform and nothing else. Everything
semantic is upstream: the bubble is a tooltip, the trigger is linked to it
with aria-describedby, and it opens on hover and on keyboard focus with
Escape to dismiss.
Attribute inheritance is off and $attrs is forwarded to UTooltip, which in
turn forwards anything it does not declare onto the trigger. So aria-label,
data-* and id land on your trigger element — and so do the upstream props
this component does not re-declare: disableHoverableContent,
disableClosingTrigger, ignoreNonKeyboardFocus and reference all pass
through as ordinary attributes.
A tooltip is not a popover: it is not dismissable by clicking inside it and its
content is not reachable by keyboard. If the content needs to be clickable or
tabbable, use MRichPopover instead.
Reduced motion is honoured. useReducedMotion() reduces the entrance to a bare
opacity fade and replaces the spring with a zero-duration transition, so the
tooltip appears without the scale or the 4px offset.
Performance
scale,x,yandopacityonly — the entrance never touches a layout property, so showing a tooltip costs no reflow.- The 4px offset is baked into the initial state rather than animated as a separate step, so the scale and the slide resolve on one spring curve.
- Dismissing hands back to Nuxt UI: the bubble unmounts at once instead of springing out, so a tooltip you move away from never lingers under the cursor.
will-change-transformis set permanently on the wrapper. That is one promoted layer per open tooltip and it is released when the bubble unmounts.- No timers and no listeners of its own; delay, dismissal and positioning are entirely upstream.
UTooltip a #content template, upstream's
"no text, no kbds, no content slot ⇒ disable the tooltip" check is always
satisfied. A tooltip with a bare trigger and nothing else still opens — as an
empty bubble. Always give it text, kbds or the content slot.