Blob Cursor
Cursors & Pointer

Blob Cursor

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.

Install

npx shadcn@latest add @paragon/blob-cursor

blob-cursor.tsx

"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>
  );
}