Workspace / component / animations
Strands
paxfx add blocks/component-animations-strandslocal onlyTier: high. Reduced motion: Strands hang in their settled catenary curves.
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { cn, useReducedMotion } from "@pax-sh/blocks";
export interface StrandsProps {
/** Number of strands (2 to 8). */
count?: number;
/** Pointer influence radius in pixels. */
pullRadius?: 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 StrandPoint {
x: number;
y: number;
px: number;
py: number;
}
interface Strand {
points: StrandPoint[];
segment: number;
alpha: number;
}
const POINTS_PER_STRAND = 22;
const GRAVITY = 900;
const DAMPING = 0.985;
/**
* Rope strands anchored at both ends along the top edge, simulated with
* verlet integration on canvas. The pointer pulls any points within its
* radius; left alone, the ropes damp back into their catenary sag and the
* loop goes to sleep until the next interaction. Strand anchors and slack
* are seeded, so the resting composition is identical on every load.
* Reduced motion draws the settled curves once and never starts the loop.
*/
export function Strands({ count = 5, pullRadius = 90, className, children }: StrandsProps) {
const reduced = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const strandCount = Math.min(8, Math.max(2, Math.round(count)));
const radius = Math.max(20, pullRadius);
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;
let strands: Strand[] = [];
const pointer = { x: -9999, y: -9999, inside: false };
let raf = 0;
let stillFrames = 0;
const buildStrands = () => {
const rand = mulberry32(0x517a4d + strandCount);
strands = Array.from({ length: strandCount }, () => {
const x0 = (0.06 + rand() * 0.3) * width;
const x1 = (0.64 + rand() * 0.3) * width;
const slack = 1.18 + rand() * 0.5;
const segment = ((x1 - x0) * slack) / (POINTS_PER_STRAND - 1);
const points = Array.from({ length: POINTS_PER_STRAND }, (_, i) => {
const t = i / (POINTS_PER_STRAND - 1);
const x = x0 + (x1 - x0) * t;
const y = Math.sin(t * Math.PI) * (x1 - x0) * (slack - 1) * 0.9;
return { x, y, px: x, py: y };
});
return { points, segment, alpha: 0.55 + rand() * 0.45 };
});
};
const step = (dt: number) => {
const dt2 = dt * dt;
const r2 = radius * radius;
for (const strand of strands) {
const pts = strand.points;
for (let i = 1; i < pts.length - 1; i += 1) {
const p = pts[i];
if (!p) continue;
const vx = (p.x - p.px) * DAMPING;
const vy = (p.y - p.py) * DAMPING;
p.px = p.x;
p.py = p.y;
p.x += vx;
p.y += vy + GRAVITY * dt2;
if (pointer.inside) {
const dx = pointer.x - p.x;
const dy = pointer.y - p.y;
const d2 = dx * dx + dy * dy;
if (d2 < r2 && d2 > 0.01) {
const pull = (1 - d2 / r2) * 0.16;
p.x += dx * pull;
p.y += dy * pull;
}
}
}
for (let iter = 0; iter < 3; iter += 1) {
for (let i = 0; i < pts.length - 1; i += 1) {
const a = pts[i];
const b = pts[i + 1];
if (!a || !b) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.hypot(dx, dy) || 0.0001;
const diff = ((dist - strand.segment) / dist) * 0.5;
const ox = dx * diff;
const oy = dy * diff;
const aFixed = i === 0;
const bFixed = i + 1 === pts.length - 1;
if (!aFixed) {
a.x += ox * (bFixed ? 2 : 1);
a.y += oy * (bFixed ? 2 : 1);
}
if (!bFixed) {
b.x -= ox * (aFixed ? 2 : 1);
b.y -= oy * (aFixed ? 2 : 1);
}
}
}
}
};
const draw = () => {
ctx.clearRect(0, 0, width, height);
ctx.lineWidth = 1.6;
ctx.lineCap = "round";
for (const strand of strands) {
const pts = strand.points;
const first = pts[0];
if (!first) continue;
ctx.strokeStyle = `rgba(200,255,0,${strand.alpha.toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(first.x, first.y);
for (let i = 1; i < pts.length - 1; i += 1) {
const p = pts[i];
const next = pts[i + 1];
if (!p || !next) continue;
ctx.quadraticCurveTo(p.x, p.y, (p.x + next.x) / 2, (p.y + next.y) / 2);
}
const last = pts[pts.length - 1];
if (last) ctx.lineTo(last.x, last.y);
ctx.stroke();
}
};
const settle = (iterations: number) => {
for (let i = 0; i < iterations; i += 1) step(1 / 60);
};
const energy = () => {
let total = 0;
for (const strand of strands) {
for (const p of strand.points) {
total += Math.abs(p.x - p.px) + Math.abs(p.y - p.py);
}
}
return total;
};
const frame = () => {
raf = 0;
step(1 / 60);
draw();
if (energy() < 0.6 && !pointer.inside) stillFrames += 1;
else stillFrames = 0;
if (stillFrames < 45 && !document.hidden) raf = requestAnimationFrame(frame);
};
const wake = () => {
stillFrames = 0;
if (!raf && !document.hidden && !reduced) raf = requestAnimationFrame(frame);
};
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);
buildStrands();
settle(240);
draw();
wake();
};
const onMove = (event: PointerEvent) => {
const rect = root.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
pointer.inside = true;
wake();
};
const onLeave = () => {
pointer.inside = false;
wake();
};
const onVisibility = () => {
if (document.hidden) {
if (raf) cancelAnimationFrame(raf);
raf = 0;
} else {
wake();
}
};
const observer = new ResizeObserver(resize);
observer.observe(root);
resize();
if (!reduced) {
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
document.addEventListener("visibilitychange", onVisibility);
}
return () => {
observer.disconnect();
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
document.removeEventListener("visibilitychange", onVisibility);
if (raf) cancelAnimationFrame(raf);
};
}, [strandCount, radius, reduced]);
return (
<div
ref={rootRef}
className={cn(
"relative isolate min-h-[280px] 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-strands"
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[1]"
/>
<div className="pointer-events-none relative z-[2]">
{children ?? (
<div className="flex min-h-[280px] flex-col justify-end p-8">
<p className="m-0 text-xs font-medium uppercase tracking-[0.22em] text-[var(--pax-mute,#6b645c)]">
Strands
</p>
<h3 className="mt-3 max-w-md text-2xl font-semibold leading-snug tracking-tight [text-wrap:balance]">
Drag the pointer through the ropes and let them swing home.
</h3>
<p className="mt-3 max-w-sm text-sm leading-relaxed text-[var(--pax-mute,#6b645c)]">
Verlet integration that sleeps the moment the ropes settle.
</p>
</div>
)}
</div>
</div>
);
}