Workspace / component / animations

Strands

Rope-like strands anchored to the edges sway with simulated tension and respond to pointer pull. Verlet integration on canvas, deterministic at rest.

Workspacecomponentanimations

paxfx add blocks/component-animations-strandslocal only

Tier: 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>
  );
}