A gooey metaball cursor built from an SVG goo filter — lead and follower blobs fuse into a liquid ligament on fast moves and squish together on press.
npx shadcn@latest add @paragon/blob-cursor"use client";
import * as React from "react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* BlobCursor — a gooey metaball cursor. A crisp lead blob tracks the pointer
* while a laggier follower blob trails behind; an SVG `feGaussianBlur` +
* `feColorMatrix` "goo" filter fuses them, so they stretch into a liquid
* ligament on fast moves and pinch apart as they catch up. Pressing squashes
* the blobs with a volume-preserving squish (flatter + wider), and on entry the
* whole chain teleports to the pointer so nothing streaks in from offscreen.
*
* Wrap any content: `<BlobCursor>…surface…</BlobCursor>`. Native cursor hidden
* only within the surface; layer is pointer-events-none. Gated on fine-pointer
* devices; under `prefers-reduced-motion` the native cursor is kept and nothing
* renders.
*/
export interface BlobCursorProps extends React.ComponentProps<"div"> {
/** Blob fill color. Defaults to the primary token. */
color?: string;
/** Lead blob diameter in px. */
size?: number;
/** Goo strength — higher fuses the blobs into longer ligaments. 0–1. */
gooeyness?: number;
}
const LEAD_SPRING: SpringOptions = { stiffness: 500, damping: 30, mass: 0.6 };
const FOLLOW_SPRING: SpringOptions = { stiffness: 180, damping: 20, mass: 0.9 };
const PRESS_SPRING: SpringOptions = { stiffness: 500, damping: 26, mass: 0.5 };
export function BlobCursor({
color = "var(--color-primary)",
size = 26,
gooeyness = 0.7,
className,
children,
...props
}: BlobCursorProps) {
const hostRef = React.useRef<HTMLDivElement>(null);
const filterId = React.useId().replace(/[:]/g, "");
const [fine, setFine] = React.useState(false);
const [inside, setInside] = React.useState(false);
const insideRef = React.useRef(false);
const reduced = useReducedMotion();
const active = fine && !reduced;
const x = useMotionValue(-9999);
const y = useMotionValue(-9999);
const leadX = useSpring(x, LEAD_SPRING);
const leadY = useSpring(y, LEAD_SPRING);
const followX = useSpring(x, FOLLOW_SPRING);
const followY = useSpring(y, FOLLOW_SPRING);
// Press squish: scaleY drops toward pressScale while scaleX bulges outward,
// so the blob flattens like pressed liquid instead of merely shrinking.
const press = useMotionValue(1);
const squishY = press;
const squishX = useTransform(press, (p) => 1 + (1 - p) * 0.55);
// Goo blur radius scales with the requested gooeyness and blob size.
const blur = Math.max(2, Math.round((0.2 + gooeyness * 0.6) * size));
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
React.useEffect(() => {
const el = hostRef.current;
if (!el || !active) return;
const teleport = (px: number, py: number) => {
x.jump(px);
y.jump(py);
leadX.jump(px);
leadY.jump(py);
followX.jump(px);
followY.jump(py);
};
const enterAt = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
teleport(e.clientX - rect.left, e.clientY - rect.top);
insideRef.current = true;
setInside(true);
};
const onMove = (e: PointerEvent) => {
if (!insideRef.current) {
enterAt(e);
return;
}
const rect = el.getBoundingClientRect();
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
};
const onEnter = (e: PointerEvent) => enterAt(e);
const onLeave = () => {
insideRef.current = false;
setInside(false);
animate(press, 1, PRESS_SPRING);
};
const onDown = () => animate(press, 0.78, PRESS_SPRING);
const onUp = () => animate(press, 1, PRESS_SPRING);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);
el.addEventListener("pointerdown", onDown);
el.addEventListener("pointerup", onUp);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("pointerleave", onLeave);
el.removeEventListener("pointerdown", onDown);
el.removeEventListener("pointerup", onUp);
};
}, [active, press, x, y, leadX, leadY, followX, followY]);
const followSize = size * 0.82;
return (
<div
ref={hostRef}
className={cn(
"relative overflow-hidden",
active && "[&_*]:cursor-none",
className,
)}
style={active ? { cursor: "none" } : undefined}
{...props}
>
{children}
{active && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-50"
style={{ opacity: inside ? 1 : 0, transition: "opacity 160ms ease" }}
>
<svg
width="0"
height="0"
className="absolute"
aria-hidden
focusable="false"
>
<defs>
<filter id={`goo-${filterId}`}>
<feGaussianBlur
in="SourceGraphic"
stdDeviation={blur}
result="blur"
/>
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9"
result="goo"
/>
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
</svg>
<div
className="absolute inset-0"
style={{ filter: `url(#goo-${filterId})` }}
>
<motion.span
className="absolute top-0 left-0 rounded-full"
style={{
x: followX,
y: followY,
width: followSize,
height: followSize,
marginLeft: -followSize / 2,
marginTop: -followSize / 2,
background: color,
scaleX: squishX,
scaleY: squishY,
}}
/>
<motion.span
className="absolute top-0 left-0 rounded-full"
style={{
x: leadX,
y: leadY,
width: size,
height: size,
marginLeft: -size / 2,
marginTop: -size / 2,
// A soft specular sitting off-center sells the liquid ball.
background: `radial-gradient(circle at 32% 28%, rgba(255,255,255,0.35), transparent 55%), ${color}`,
scaleX: squishX,
scaleY: squishY,
}}
/>
</div>
</div>
)}
</div>
);
}