MynduiMyndui
0

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.

01/04Dot, trail, portal

createPortal(…, containerRef ?? document.body)

Trail
size × 2.4 · blur-md · lerp 0.12
Dot
leading circle · lerp 0.25 · same center at rest
tsx
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,);
01

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.

02/04Three lerps, one loop

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
tsx
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})`;
02

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).

03/04Cheap when idle
chasing
settled / hidden

if (settled() || document.hidden) { raf = 0; return; }

Running
pointer active · deltas ≥ SETTLE · rAF spinning
Idle
settled or document.hidden — raf = 0
tsx
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;}
03

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.

04/04Result

Move your pointer inside the card

Or me
04

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.

tsx
 
const fine = window.matchMedia("(pointer: fine)").matches;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
setEnabled(fine && !reduce);

Motion Score

Fluid CursorSSCompositor-only
StranslatePosition / lift via translate
SscaleScale spring / press
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →