# Tooltip Spring Follow — React Tooltip Component

> A tooltip that trails the cursor on a spring and tilts with its velocity, like a tag swinging from the pointer.

- Category: [Tooltips & Popovers](https://pixelpapa.com/components/tooltips.md)
- Tags: tooltip, cursor, follow, spring, velocity
- Dependencies: motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/tooltips/tooltip-spring-follow

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/tooltip-spring-follow.json"
```

The file is written to `components/pixelpapa/tooltip-spring-follow.tsx`. To install manually, run `npm install motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity } from "motion/react";
import { useId, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

type FollowTooltipProps = {
  /** Tooltip content. */
  label: ReactNode;
  children: ReactNode;
  /** Show the tooltip at rest (until the pointer leaves once). */
  defaultOpen?: boolean;
  className?: string;
};

export function FollowTooltip({ label, children, defaultOpen = false, className }: FollowTooltipProps) {
  const id = useId();
  const ref = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const [open, setOpen] = useState(defaultOpen);
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const sx = useSpring(x, { stiffness: 320, damping: 24, mass: 0.5 });
  const sy = useSpring(y, { stiffness: 320, damping: 24, mass: 0.5 });
  // Horizontal speed becomes tilt: the tag lags behind the direction of travel.
  const tilt = useTransform(useVelocity(sx), [-1200, 0, 1200], [-16, 0, 16], { clamp: true });
  const rotate = useSpring(tilt, { stiffness: 220, damping: 16 });

  function place(px: number, py: number, instant: boolean) {
    x.set(px);
    y.set(py);
    if (instant || reduce) {
      sx.jump(px);
      sy.jump(py);
    }
  }

  useLayoutEffect(() => {
    const el = ref.current;
    if (defaultOpen && el) place(el.offsetWidth * 0.62, el.offsetHeight * 0.42, true);
    // Only for the initial resting position.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function local(e: PointerEvent<HTMLDivElement>): [number, number] {
    const r = e.currentTarget.getBoundingClientRect();
    return [e.clientX - r.left, e.clientY - r.top];
  }

  return (
    <div
      ref={ref}
      aria-describedby={open ? id : undefined}
      onPointerEnter={(e) => {
        if (e.pointerType === "touch") return;
        place(...local(e), !open);
        setOpen(true);
      }}
      onPointerMove={(e) => e.pointerType !== "touch" && place(...local(e), false)}
      onPointerLeave={() => setOpen(false)}
      onFocus={(e) => {
        // Keyboard users get it anchored to the top centre of the target.
        if (!e.currentTarget.matches(":hover")) place(e.currentTarget.offsetWidth / 2, 8, !open);
        setOpen(true);
      }}
      onBlur={() => setOpen(false)}
      className={cn("relative", className)}
    >
      {children}
      <AnimatePresence>
        {open && (
          <motion.div
            id={id}
            role="tooltip"
            style={{ x: sx, y: sy }}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: 0.12 } }}
            className="pointer-events-none absolute top-0 left-0 z-30"
          >
            <motion.div
              style={{ rotate: reduce ? 0 : rotate, transformOrigin: "0% 0%" }}
              initial={{ scale: 0.5 }}
              animate={{ scale: 1 }}
              exit={{ scale: 0.7 }}
              transition={{ type: "spring", stiffness: 500, damping: 26 }}
              className="mt-4 ml-4 w-max max-w-56 rounded-xl rounded-tl-sm bg-foreground px-3 py-2 text-xs font-medium text-background shadow-xl"
            >
              {label}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

const places = [
  { name: "Lofoten, Norway", meta: "14 photos · Feb 2026", sky: "from-chart-2/70 to-chart-1/80", hill: "var(--chart-1)" },
  { name: "Kyoto in autumn", meta: "32 photos · Nov 2025", sky: "from-chart-4/70 to-destructive/60", hill: "var(--chart-3)" },
  { name: "Atacama desert", meta: "9 photos · Aug 2025", sky: "from-chart-5/60 to-chart-2/70", hill: "var(--chart-4)" },
];

export default function TooltipSpringFollowDemo() {
  return (
    <div className="grid w-full max-w-xl grid-cols-3 gap-4">
      {places.map((p, i) => (
        <FollowTooltip
          key={p.name}
          defaultOpen={i === 1}
          label={
            <span className="block">
              <span className="block text-[13px]">{p.name}</span>
              <span className="block font-normal opacity-70">{p.meta}</span>
            </span>
          }
        >
          <a
            href="#"
            aria-label={p.name}
            className={cn("relative block h-56 overflow-hidden rounded-2xl border border-border bg-linear-to-b", p.sky)}
          >
            <span className="absolute top-7 right-7 size-9 rounded-full bg-white/80 blur-[1px]" />
            <svg aria-hidden viewBox="0 0 100 60" preserveAspectRatio="none" className="absolute inset-x-0 bottom-0 h-3/5 w-full">
              <path d="M0 38 L18 16 L34 34 L52 8 L72 36 L86 22 L100 34 V60 H0Z" fill={p.hill} opacity="0.55" />
              <path d="M0 48 L22 30 L40 46 L60 26 L82 46 L100 36 V60 H0Z" fill={p.hill} />
            </svg>
          </a>
        </FollowTooltip>
      ))}
    </div>
  );
}
```

## AI prompt

```text
Add the "Tooltip Spring Follow" component from PixelPapa UI to this project.

What it is: A tooltip that trails the cursor on a spring and tilts with its velocity, like a tag swinging from the pointer.
Category: Tooltips & Popovers. Depends on: motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/tooltip-spring-follow.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/tooltip-spring-follow.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/tooltip-spring-follow" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More tooltips & popovers

- [Hover Card Link Preview](https://pixelpapa.com/components/tooltips/hover-card-link-preview.md): Inline links that open a hover card with a drawn miniature of the destination page, which slowly scrolls while you read the summary.
- [Popover Color Picker Mini](https://pixelpapa.com/components/tooltips/popover-color-picker-mini.md) (Pro): A compact colour popover with a draggable saturation field, hue slider, swatches and hex input that springs out of its swatch trigger.
- [Popover Share Menu](https://pixelpapa.com/components/tooltips/popover-share-menu.md) (Pro): A share popover with a public-link switch, a copy button that flips to a confirmed state with a sweep across the URL, and quick share targets.
- [Tooltip Rich Card](https://pixelpapa.com/components/tooltips/tooltip-rich-card.md): A toolbar whose rich tooltip (title, body, shortcut keys) glides from button to button instead of closing and reopening.
