Anatomy of Fluid Cursor
How a portal-rendered dot and lagging trail chase the pointer with three lerps — and why the rAF loop stops the moment everything settles.
↓Scroll to step through it
Dot, trail, portal
FluidCursor isn't a CSS cursor: swap. It portals a custom stack into the
page (or a scoped container), hides the system cursor in that region, and
drives two circles with a requestAnimationFrame loop that only runs while
there's catching-up left to do.
When enabled, the component createPortals a pointer-events-none overlay
into containerRef.current (or document.body). Inside: an optional soft
trail (size × 2.4, blur-md) and a sharp leading dot. With blend on, the
whole stack uses mix-blend-mode: difference so a white fill inverts whatever
sits underneath.
Three lerps, one loop
Every frame the loop nudges three values toward their targets: position for
the dot (0.25), position for the trail (0.12), and a hover amount
(0.15). Interactive targets — a, button, [role='button'],
[data-cursor] by default — set thover to 1, which scales the dot to
1 + hover × 1.6 (trail to 1 + hover × 0.8).
Direct style writes — no React re-render per move. The first pointermove
snaps all positions to the pointer so the cursor doesn't fly in from (0,0).
Cheap when idle
SETTLE is 0.01. When every delta is under that threshold — or the tab is
hidden — the loop sets raf = 0 and returns. The next pointermove /
pointerover / visibility restore calls start() again. A resting cursor
costs zero frames.
The result
Move inside the card — hover the button and the [data-cursor] chip to feel
the scale lerp.
Two circles, three lerps, and an rAF loop that refuses to spin when nothing is moving.
createPortal(…, containerRef ?? document.body)
- Trail
- size × 2.4 · blur-md · lerp 0.12
- Dot
- leading circle · lerp 0.25 · same center at rest
return createPortal(<div aria-hidden className={`pointer-events-none ${scoped ? "absolute" : "fixed"} inset-0 z-toast … ${blend ? "mix-blend-difference" : ""}`}> {trail ? <div ref={trailRef} className="… blur-md" style={{ width: size * 2.4, … }} /> : null} <div ref={dotRef} style={{ width: size, height: size, background: color }} /></div>,target,);Anatomy of Fluid Cursor
How a portal-rendered dot and lagging trail chase the pointer with three lerps — and why the rAF loop stops the moment everything settles.
createPortal(…, containerRef ?? document.body)
- Trail
- size × 2.4 · blur-md · lerp 0.12
- Dot
- leading circle · lerp 0.25 · same center at rest
return createPortal(<div aria-hidden className={`pointer-events-none ${scoped ? "absolute" : "fixed"} inset-0 z-toast … ${blend ? "mix-blend-difference" : ""}`}> {trail ? <div ref={trailRef} className="… blur-md" style={{ width: size * 2.4, … }} /> : null} <div ref={dotRef} style={{ width: size, height: size, background: color }} /></div>,target,);Dot, trail, portal
FluidCursor isn't a CSS cursor: swap. It portals a custom stack into the
page (or a scoped container), hides the system cursor in that region, and
drives two circles with a requestAnimationFrame loop that only runs while
there's catching-up left to do.
When enabled, the component createPortals a pointer-events-none overlay
into containerRef.current (or document.body). Inside: an optional soft
trail (size × 2.4, blur-md) and a sharp leading dot. With blend on, the
whole stack uses mix-blend-mode: difference so a white fill inverts whatever
sits underneath.
translate3d(…) translate(-50%, -50%) scale(…)
- Dot lerp 0.25
- x += (tx − x) × 0.25 each frame
- Trail lerp 0.12
- sx += (tx − sx) × 0.12 — soft lag
- Hover lerp 0.15
- scale(1 + hover × 1.6) on interactive
s.x += (s.tx - s.x) * 0.25;s.y += (s.ty - s.y) * 0.25;s.sx += (s.tx - s.sx) * 0.12;s.sy += (s.ty - s.sy) * 0.12;s.hover += (s.thover - s.hover) * 0.15;dot.style.transform = `translate3d(${s.x}px, ${s.y}px, 0) translate(-50%, -50%) scale(${1 + s.hover * 1.6})`;Three lerps, one loop
Every frame the loop nudges three values toward their targets: position for
the dot (0.25), position for the trail (0.12), and a hover amount
(0.15). Interactive targets — a, button, [role='button'],
[data-cursor] by default — set thover to 1, which scales the dot to
1 + hover × 1.6 (trail to 1 + hover × 0.8).
Direct style writes — no React re-render per move. The first pointermove
snaps all positions to the pointer so the cursor doesn't fly in from (0,0).
if (settled() || document.hidden) { raf = 0; return; }
- Running
- pointer active · deltas ≥ SETTLE · rAF spinning
- Idle
- settled or document.hidden — raf = 0
const SETTLE = 0.01;const settled = () =>Math.abs(s.tx - s.x) < SETTLE &&Math.abs(s.ty - s.y) < SETTLE &&/* …trail + hover… */;if (settled() || document.hidden) {raf = 0;return;}Cheap when idle
SETTLE is 0.01. When every delta is under that threshold — or the tab is
hidden — the loop sets raf = 0 and returns. The next pointermove /
pointerover / visibility restore calls start() again. A resting cursor
costs zero frames.
Move your pointer inside the card
The result
Move inside the card — hover the button and the [data-cursor] chip to feel
the scale lerp.
Two circles, three lerps, and an rAF loop that refuses to spin when nothing is moving.
Who gets a custom cursor
Enablement is gated at mount: (pointer: fine) and not
prefers-reduced-motion. Touch, stylus-coarse, and reduced-motion users get
null — no overlay, no listeners. Scoped mode also tracks
pointerenter/pointerleave so the cursor fades out when you leave the
container.
const fine = window.matchMedia("(pointer: fine)").matches;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
setEnabled(fine && !reduce);Motion Score
translatePosition / lift via translatescaleScale spring / pressopacityFade / cross-fade