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.
↓Scroll to step through it
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.
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.
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.
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.
- 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
<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>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.
- 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
<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>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.
{ 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
<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 }}/>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.
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]
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);};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.
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
translatePosition / lift via translaterotateRotationopacityFade / cross-fade