GodUIGodUI
81

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.

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 exact same { stiffness: 170, damping: 12, mass: 0.1 } config so the whole thing feels like a single physical object.

Anatomy

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

Anatomyone wrapper · panel + caret + trigger
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
 
<span
  onMouseEnter={() => 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>

The caret isn't a triangle or an SVG — it's a 2px square, rotate-45, tucked half-behind the panel's edge. Cheaper than a clip-path, and it inherits the panel's own background so there's nothing to keep in sync.

The motion

The panel's initial/animate pair isn't a fade with a duration; it's a spring, and a deliberately underdamped one. damping: 12 against stiffness: 170 with a featherweight mass: 0.1 means it doesn't ease up to scale: 1 — it flies past it and springs back:

The motionmount spring · overshoot · settle

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

opacity
0 → 1, tied to the same spring
scale
0.85 → 1, overshoots to ~1.09 first
y
8px → 0, settles a beat after scale peaks
tsx
 
<motion.span
  initial={{ 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 }}
/>

Notice the exit isn't a spring at all — no transition is specified, so it falls back to Framer's default tween. The bounce is a hello, not a goodbye: reserved for the moment the tooltip earns your attention, 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 — not the page — so the math is symmetric around zero regardless of where the trigger sits:

Pointer tiltx → spring → rotate + x

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

pointer x
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);
};

useTransform is the instant, unanimated remap — pure interpolation, no easing. useSpring is what 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 — no 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.

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, four event handlers, all routed through the identical AnimatePresence.

The result

Resulthover or tab to a trigger
ALGHAT

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.

On this page

Built with GodUI

Beautifully crafted motion components for modern interfaces.

Star on GitHub