GSAP Cursor Follower
A container hides its native cursor and draws its own: a small disc that lags the pointer by a settling tween, and swells into a labelled puck whenever it passes over an element marked `data-cursor-target`. The lag comes from GSAP rather than from a damped rAF loop, which is why the disc comes to rest when the pointer stops instead of asymptoting toward it forever.
GSAP Cursor Follower
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/gsap-cursor-follower.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Panel content. Anything inside carrying data-cursor-target triggers the labelled state. |
| label | string | = 'View' | Text revealed inside the puck over a target. |
| duration | number | = 0.4 | Seconds of catch-up on the follow tween. |
| size | number | = 16 | Resting diameter in px. |
| targetSize | number | = 112 | Diameter over a target, in px. Also the disc's laid-out size. |
| className | string | — | Merged onto the root container via cn. |
How it works
The technique in order, not the code in order.
On mount the puck is centred on its own coordinates with
gsap.set(circle, { xPercent: -50, yPercent: -50 })and the positioning wrapper is parked atx: -999, y: -999. From then on the follow tween writes raw x/y and the centring composes into the same matrix, so there is no per-move offset arithmetic and exactly one composited transform per node.Every
pointermoveissuesgsap.to(wrapper, { x: e.clientX - rect.left, y: e.clientY - rect.top, duration, ease: "power2.out", overwrite: "auto" }).overwrite: "auto"retires the previous tween so the ~120 events a second do not accumulate into ~120 concurrent tweens writing the same transform.This is deliberately GSAP smoothing rather than
useSmoothPointer. A tween re-eases from its current velocity to a *rest* at the target, so the puck decelerates into stillness the instant the pointer stops; an exponentialdampnever actually arrives. Reach for the damped hook when you want a value that is permanently chasing, and for this when you want it to settle.pointerenterteleports first (duration: 0) and only then fades in, so re-entering the panel does not drag a visible streak across it from wherever the puck was last parked.The hover state is delegated: one handler runs
e.target.closest("[data-cursor-target]")per move and compares the result with the previous state, tweeningscaleon the disc andautoAlpha/scaleon the label only on the transition. Scale — not width/height — keeps the resize on the compositor with no layout pass.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| PARK | = -999 | Initial x/y. The element exists from first paint but must be *nowhere*; at 0,0 it flashes in the top-left corner for the frames between mount and the first pointer event. |
| duration | = 0.4 | Seconds of catch-up. Below ~0.2 the puck is welded to the pointer and the trailing read disappears; past ~0.7 it feels detached from the hand and users start chasing it. |
| size | = 16 | Resting diameter. Small enough to point precisely with, large enough to see against a busy panel. Applied as scale: size / targetSize so only the transform changes. |
| targetSize | = 112 | Diameter over a target. It has to hold a short label at 14px with real breathing room — under ~90 the text touches the circle edge and the puck reads as a broken badge. |
| RESIZE_DURATION | = 0.45 | The swell. With expo.out this is almost all its motion in the first 120ms, which is what makes the state change feel like a snap rather than an inflation. |
| FADE_DURATION | = 0.3 | Enter/leave opacity. Fast enough that the puck is present before the user has moved a meaningful distance into the panel. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The puck still appears and still swells over targets — the affordance is preserved — but every duration collapses to 0, so it is welded to the pointer with no trailing lag and the size change is a hard cut. The native cursor stays hidden, because a container with no cursor at all would be worse than one that moves.
Removes the three container listeners, restores the container's previous inline cursor, calls gsap.killTweensOf on the wrapper, disc and label, then gsap.set(..., { clearProps: "all" }). Kill precedes clear so no in-flight tween can write the transform back after React has recycled the node.
Move into the panel: the arrow disappears and a small disc arrives at the pointer without sweeping in from an edge, then trails about 0.4s behind as you move. Pass over a marked target and it swells into a labelled puck; leave and it shrinks back. Stop moving and it comes fully to rest rather than creeping. On a touch device the native cursor and all default behaviour are untouched.
Pitfalls
- Calling
gsap.toper pointermove withoutoverwrite: "auto". Sixty to a hundred and twenty 0.4s tweens end up live at once, all writing the same transform, and the puck stutters instead of gliding — the single most common way this effect is got wrong. - Fading in without teleporting first. On every re-entry the puck tweens from its parked coordinates, drawing a diagonal streak across the whole panel before it reaches the pointer.
- Re-issuing the resize tween on every move instead of only on the hover transition. Each call restarts the
expo.outease from the beginning, so the puck creeps toward the target size and never gets there while the pointer keeps moving. - Transitioning
widthandheightfor the swell. That is a layout write per frame on an element the compositor could otherwise leave alone;scaleon a fixed-size disc is the same picture for free. - Caching the container rect on mount. The panel scrolls, resizes, and may sit inside a transformed ancestor — a stale rect offsets the puck from the real pointer by a constant amount that never corrects itself.
- Putting
cursor: nonein the class list. It then applies on touch devices too, where it hides nothing but does suppress the text-selection caret; setting it from the effect means it only ever exists where the puck does.
Notes
- Gated on
(pointer: fine). On touch nothing attaches: no listeners, nocursor: none, and the puck stays atautoAlpha: 0parked off-container, so a tap does not flash a disc at the touch point. - Mark any element inside with
data-cursor-targetto trigger the labelled state — including elements nested arbitrarily deep, since the check is aclosest()walk. - The root sets
isolate, so the puck stacks above the panel content without needing a page-level z-index.