Workspace / component / animations
Splash Cursor
paxfx add blocks/component-animations-splash-cursorlocal onlyTier: 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>
);
}