Workspace / component / animations

Splash Cursor

Clicks detonate ink splashes that bloom and dissolve on a canvas layer over your content. Palette follows brand tokens.

Workspacecomponentanimations

paxfx add blocks/component-animations-splash-cursorlocal only

Tier: high. Reduced motion: Clicks produce a brief static ring, no bloom.

"use client";

import { useEffect, useRef, type ReactNode } from "react";
import { cn, useReducedMotion } from "@pax-sh/blocks";

export interface SplashCursorProps {
  /** Droplets per splash (6 to 32). */
  droplets?: number;
  /** Splash life in milliseconds. */
  duration?: number;
  className?: string;
  children?: ReactNode;
}

function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

interface Droplet {
  angle: number;
  speed: number;
  radius: number;
}

interface Splash {
  x: number;
  y: number;
  born: number;
  droplets: Droplet[];
}

/**
 * Pointer clicks detonate ink splashes on a canvas overlay: a pressure
 * ring blooms outward while seeded droplets scatter, decelerate, and
 * dissolve. Each click's droplet pattern is deterministic from a click
 * counter seed, so the same session replays identically. The rAF loop
 * runs only while splashes are alive. Reduced motion swaps the bloom for
 * a brief static ring that fades.
 */
export function SplashCursor({
  droplets = 18,
  duration = 750,
  className,
  children,
}: SplashCursorProps) {
  const reduced = useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const dropletCount = Math.min(32, Math.max(6, Math.round(droplets)));
  const life = Math.max(200, duration);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let width = 0;
    let height = 0;
    const splashes: Splash[] = [];
    let raf = 0;
    let clickCount = 0;

    const resize = () => {
      const rect = root.getBoundingClientRect();
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };

    const draw = (now: number) => {
      ctx.clearRect(0, 0, width, height);
      for (let s = splashes.length - 1; s >= 0; s -= 1) {
        const splash = splashes[s];
        if (!splash) continue;
        const t = (now - splash.born) / life;
        if (t >= 1) {
          splashes.splice(s, 1);
          continue;
        }
        const fade = 1 - t;
        if (reduced) {
          ctx.strokeStyle = `rgba(200,255,0,${(fade * 0.9).toFixed(3)})`;
          ctx.lineWidth = 2;
          ctx.beginPath();
          ctx.arc(splash.x, splash.y, 22, 0, Math.PI * 2);
          ctx.stroke();
          continue;
        }
        const bloom = 1 - (1 - t) * (1 - t);
        ctx.strokeStyle = `rgba(200,255,0,${(fade * 0.8).toFixed(3)})`;
        ctx.lineWidth = 2.5 * fade + 0.5;
        ctx.beginPath();
        ctx.arc(splash.x, splash.y, 6 + bloom * 46, 0, Math.PI * 2);
        ctx.stroke();
        ctx.fillStyle = `rgba(200,255,0,${(fade * 0.95).toFixed(3)})`;
        for (const d of splash.droplets) {
          const dist = d.speed * bloom;
          const dx = splash.x + Math.cos(d.angle) * dist;
          const dy = splash.y + Math.sin(d.angle) * dist - bloom * bloom * 10;
          const r = d.radius * (1 - t * 0.7);
          if (r <= 0.2) continue;
          ctx.beginPath();
          ctx.arc(dx, dy, r, 0, Math.PI * 2);
          ctx.fill();
        }
      }
    };

    const frame = (now: number) => {
      raf = 0;
      draw(now);
      if (splashes.length > 0 && !document.hidden) raf = requestAnimationFrame(frame);
    };

    const wake = () => {
      if (!raf && !document.hidden) raf = requestAnimationFrame(frame);
    };

    const onDown = (event: PointerEvent) => {
      const rect = root.getBoundingClientRect();
      clickCount += 1;
      const rand = mulberry32(0x5eed + clickCount * 7919);
      splashes.push({
        x: event.clientX - rect.left,
        y: event.clientY - rect.top,
        born: performance.now(),
        droplets: Array.from({ length: dropletCount }, () => ({
          angle: rand() * Math.PI * 2,
          speed: 26 + rand() * 64,
          radius: 1.5 + rand() * 3.5,
        })),
      });
      wake();
    };
    const onVisibility = () => {
      if (document.hidden) {
        if (raf) cancelAnimationFrame(raf);
        raf = 0;
      } else if (splashes.length > 0) {
        wake();
      }
    };

    const observer = new ResizeObserver(resize);
    observer.observe(root);
    resize();
    root.addEventListener("pointerdown", onDown);
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      observer.disconnect();
      root.removeEventListener("pointerdown", onDown);
      document.removeEventListener("visibilitychange", onVisibility);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [dropletCount, life, reduced]);

  return (
    <div
      ref={rootRef}
      className={cn(
        "relative isolate min-h-[240px] cursor-crosshair overflow-hidden rounded-2xl border border-[var(--pax-border,rgba(18,17,15,0.12))]",
        "bg-[var(--pax-night,#0b0d10)] text-[var(--pax-paper,#f6f2ea)]",
        className,
      )}
      data-pax-asset="component-animations-splash-cursor"
    >
      <div className="relative z-[1]">
        {children ?? (
          <div className="flex min-h-[240px] flex-col items-center justify-center p-8 text-center">
            <p className="m-0 text-xs font-medium uppercase tracking-[0.22em] text-[var(--pax-mute,#6b645c)]">
              Splash
            </p>
            <h3 className="mt-3 max-w-md text-2xl font-semibold leading-snug tracking-tight [text-wrap:balance]">
              Click anywhere on this surface.
            </h3>
            <p className="mt-3 max-w-sm text-sm leading-relaxed text-[var(--pax-mute,#6b645c)]">
              Every splash is seeded by its click count, so the ink falls the same way twice.
            </p>
          </div>
        )}
      </div>
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 z-[2]"
      />
    </div>
  );
}