GodUIGodUI
129Follow on X

Anatomy of the Animated Tooltip

How one motion.span becomes a bouncy, pointer-tracking tooltip: the mount spring, the 3D tilt math, and why both share one spring config.

01/04Anatomy
trigger
always mounted; hover/focus flips open
panel
role=tooltip, only in the DOM while open
caret
2px square rotated 45°, same fill as the panel
tsx
<spanonMouseEnter={() => setOpen(true)}onMouseLeave={() => setOpen(false)}onFocus={() => setOpen(true)}onBlur={() => setOpen(false)}onMouseMove={handleMouseMove}><AnimatePresence mode="wait">  {open ? <motion.span role="tooltip">{content}</motion.span> : null}</AnimatePresence>{children}</span>
01

Anatomy

Most tooltips are a display toggle with a CSS transition bolted on. Animated Tooltip is one <span> wrapper, an AnimatePresence, and two coupled springs (one for the mount, one for the pointer) built from the same { stiffness: 170, damping: 12, mass: 0.1 } config so the whole thing moves like a single physical object.

The wrapper never unmounts. open, flipped by onMouseEnter/onMouseLeave and onFocus/onBlur, controls whether the panel and its caret exist at all.

The caret is a 2px square, rotate-45, tucked half-behind the panel's edge instead of a triangle or an SVG. It is cheaper than a clip-path and inherits the panel's own background, so there's nothing to keep in sync.

02/04The motion

{ type: "spring", stiffness: 170, damping: 12, mass: 0.1 }

Tooltip panel
opacity 0 → 1, tied to the same spring
Overshoot
scale 0.85 → 1, overshoots to ~1.09 first
Trigger
8px → 0, settles a beat after scale peaks
tsx
<motion.spaninitial={{ opacity: 0, y: isTop ? 8 : -8, scale: 0.85 }}animate={{  opacity: 1,  y: 0,  scale: 1,  transition: { type: "spring", stiffness: 170, damping: 12, mass: 0.1 },}}exit={{ opacity: 0, y: isTop ? 6 : -6, scale: 0.9 }}/>
02

The motion

The panel's initial/animate pair is a spring rather than a fade with a duration, and a deliberately underdamped one. damping: 12 against stiffness: 170 with a featherweight mass: 0.1 means it overshoots scale: 1 and springs back instead of easing up to it.

The exit has no spring. No transition is specified, so it falls back to Framer's default tween. The bounce plays only on entry, when the tooltip asks for attention, and is skipped on the way out because nobody's watching it leave.

03/04Pointer tilt

useSpring(useTransform(x, [-60, 60], [...]), { stiffness: 170, damping: 12, mass: 0.1 })

Pointer
clientX − trigger center, raw px
Rotate
useTransform [-60,60] → [-14,14]
TranslateX
useTransform [-60,60] → [-12,12]
tsx
const x = useMotionValue(0);const rotate = useSpring(useTransform(x, [-60, 60], [-14, 14]), SPRING);const translateX = useSpring(useTransform(x, [-60, 60], [-12, 12]), SPRING);const handleMouseMove = (e: React.MouseEvent<HTMLSpanElement>) => {const rect = e.currentTarget.getBoundingClientRect();x.set(e.clientX - rect.left - rect.width / 2);};
03

Pointer tilt

The 3D read comes from a second spring riding the same config, driven by the cursor instead of a mount/unmount. onMouseMove measures the pointer against the trigger's own center rather than the page, so the math is symmetric around zero regardless of where the trigger sits.

useTransform is the instant, unanimated remap: pure interpolation with no easing. useSpring wraps around it, turning that instantaneous target into a value the panel chases. That's why the tilt reads as physical instead of glued to the cursor: at speed, the panel visibly lags a beat behind, then catches up with the same 170/12/0.1 bounce as the mount. Both transforms land on style, not animate, so they update every frame off the motion values directly, without a re-render per pixel of mouse movement.

[transform-style:preserve-3d] on the panel is what keeps rotate and the caret's own rotate-45 compositing correctly together instead of one flattening the other.

04/04Result
ALGHAT
04

The result

One spring config, two jobs: the panel springs into existence, and once it exists, the same numbers govern how it leans toward wherever you're pointing.

Accessibility

role="tooltip" is on the panel itself, and it opens on onFocus exactly like it does on onMouseEnter, so tabbing to any wrapped trigger reveals the same content a mouse hover would, with the same spring. There's no separate keyboard mode: one open boolean and four event handlers, all routed through the same AnimatePresence.

Motion Score

Animated TooltipSS: Compositor-only
StranslatePosition / lift via translate
SrotateRotation
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →