GodUIGodUI
129Follow on X

Anatomy of Fluid Cursor

How a portal-rendered dot and lagging trail chase the pointer with three lerps, and why the rAF loop stops as soon as 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 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 runs only while the circles are still catching up.

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

The loop writes styles directly, so moves never trigger a React re-render. 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 see the scale lerp.

Two circles, three lerps, and an rAF loop that stops 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 and 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 CursorSS: Compositor-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 →