Skip to main content

ns-ui / loud

Spiral Chute Accrete

A full-bleed ambient background reproducing a gravity spiral chute, the helical slide used to move bulk parcels/mail sacks between floors in sorting facilities and department-store stockrooms without powered conveyance. Parcels drop in at the top, descend the helix in continuous view, and pile up in a landing area at the base until the pile is periodically swept clear.

Use when a full-bleed decorative field where small discrete units continuously descend a helical path and accumulate into a bounded, periodically-cleared pile — pick this when the section needs an industrial-logistics mechanic with a clear cyclical event (the sweep) as its 'alive at rest' signal. Pick background-engine-turn-guilloche instead when the field should be a dense, continuously-woven interference texture rather than discrete travelling units; pick bed-fluidize instead when the surface itself needs to look like a disturbed granular bed rather than parcels riding a fixed track.

Install

npx shadcn add https://design.helpmarq.com/r/spiral-chute-accrete.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/loud/spiral-chute-accrete/component.tsx
"use client";

import { useEffect, useRef } from "react";
import type { ReactNode } from "react";

// ---------------------------------------------------------------------------
// SpiralChuteAccrete — a full-bleed ambient background reproducing a gravity
// spiral chute: the helical slide sorting facilities and department-store
// stockrooms use to move parcels/mail sacks between floors without powered
// conveyance. Small parcels enter at the top, slide down a continuous
// helical ramp under gravity alone (friction + the helix's banking keeps
// speed roughly bounded rather than accelerating), and pile up in a landing
// area at the base until the pile is swept clear.
//
// EVERYTHING IS A PURE FUNCTION OF ONE CLOCK. Rather than mutating an array
// of live parcel/tile objects frame to frame, every quantity drawn — which
// parcels are in flight, where each sits on the helix, which tiles are in
// the current pile, which are mid-sweep-out — is derived straight from the
// elapsed clock t (ms) and a fixed spawn index k. That is what makes the
// state reproducible for prefers-reduced-motion: freezing the clock at a
// named STATIC_T freezes the exact same layout every time, with no history
// to serialize.
//
//   spawn(k)   = k * SPAWN_INTERVAL_MS                      (top of chute)
//   land(k)    = spawn(k) + TRANSIT_MS                      (base of chute)
//   in flight  = spawn(k) <= t < land(k)   -> theta = (t-spawn(k))/TRANSIT_MS * THETA_MAX
//   landed     = land(k) <= t              -> becomes one pile tile
//
// PILE / SWEEP: the base landing area fills with one tile per landed
// parcel, positioned by a period-9 deterministic jitter sequence keyed off
// k % 9 (never per-frame randomness, so the pile shape is exactly
// reproducible). Every SWEEP_PERIOD_MS the chute's current 9s fill cycle
// ends and the NEXT cycle's landings start a fresh pile; the tiles that
// belong to the cycle which just ended slide off to one side over
// SWEEP_DURATION_MS, staggered SWEEP_STAGGER_MS apart, for the first
// SWEEP_DURATION_MS of the new cycle only. This is derived directly from
// `t % SWEEP_PERIOD_MS`, so a pile can never accumulate past one fill
// cycle's worth of tiles and never breaches the canvas edge.
//
// REAL NUMBERS (all fixed, real-world-derived, not tuned per frame): a
// parcel takes TRANSIT_MS to cross THE FULL 3.5 turns of the helix — this
// is one of the rare cases where the legible rate and the real rate are
// close, so descent renders close to real proportion instead of being
// artificially decoupled. New parcels spawn every SPAWN_INTERVAL_MS, which
// at a 3.6s transit keeps 2-3 parcels in flight simultaneously, each at a
// different theta so they never overlap on the same winding — this
// simultaneous-parcel count, not a faster descent, is the intended legible
// signal for "alive," per the spec's own kill criteria.
//
// TOKENS: the ramp track itself is the least important thing on screen —
// it renders at --ns-muted, a low, border-like contrast step, so it reads
// as track rather than subject. Parcels and pile tiles are the moving
// subject and render at --foreground so they clear the ramp with a real
// luminance step in both themes. --ns-accent never appears; nothing here
// is interaction chrome, and the pile-sweep moment (this component's one
// climactic event) is a --foreground fade/slide only.
// ---------------------------------------------------------------------------

const TRANSIT_MS = 3600; // one parcel's full top-to-bottom transit
const SPAWN_INTERVAL_MS = 1300; // cadence of new parcels entering at the top
const TURNS = 3.5; // total revolutions top -> bottom
const THETA_MAX = TURNS * Math.PI * 2;
const SWEEP_PERIOD_MS = 9000; // pile fill/clear cycle length
const SWEEP_DURATION_MS = 500; // how long a sweep-out takes
const SWEEP_STAGGER_MS = 30; // stagger between tiles sliding away
const RADIUS_FACTOR = 0.32; // spiral radius, as a fraction of min(w,h)
const RAMP_TOP_FRAC = 0.1; // top margin, fraction of container height
const RAMP_HEIGHT_FRAC = 0.68; // vertical extent of the helix, fraction of height
const RAMP_SAMPLES = 240; // points used to build the cached ramp path
const PARCEL_SIZE_FRAC = 0.05; // parcel edge length, fraction of min(w,h)
const PILE_JITTER_FRAC = 0.075; // pile scatter radius, fraction of min(w,h)

// deterministic period-9 jitter sequence (unit-ish offsets), keyed by
// spawn index k % 9 — fixed, not per-frame random, so the pile shape is
// reproducible frame to frame and across prefers-reduced-motion runs.
const PILE_OFFSETS: readonly [number, number][] = [
  [0.12, -0.22],
  [-0.38, 0.14],
  [0.44, 0.3],
  [-0.14, -0.42],
  [0.05, 0.42],
  [-0.46, -0.08],
  [0.26, -0.36],
  [-0.2, 0.36],
  [0.4, 0.04],
];

// prefers-reduced-motion freeze frame: named MID_DESCENT. Chosen mid-cycle
// (71.7% into a 9s fill) so the pile is roughly half of a full cycle's
// worth of tiles (3 landed of ~5 max) rather than freshly swept or about
// to overflow, with two parcels still in flight at clearly different theta
// — spiral, motion-implying spacing and an in-progress pile all visible
// in one static composition.
const STATIC_T = 6450;

// live-mode warm start: t=0 on a bare clock is a near-empty chute (one
// parcel just spawned, pile empty) — the spec's own t0 requirement is
// "2-3 parcels visible at different points on the spiral, plus a partial
// pile." Starting the live clock here instead of at 0 makes t0 already
// alive without changing any rate: k=2/k=3 in flight (theta .71/.35),
// pile at 2 tiles. Deliberately a different phase than STATIC_T so the
// reduced-motion freeze is never mistaken for "just t0."
const WARM_START_MS = 5150;

function landTimeForIndex(k: number): number {
  return k * SPAWN_INTERVAL_MS + TRANSIT_MS;
}

interface FlightParcel {
  k: number;
  progress: number; // 0..1 along the helix
}

interface PileTile {
  k: number;
  sweepProgress: number; // 0 = resting in pile, 1 = fully slid away
}

/** Parcels currently in flight (spawned, not yet landed) at clock t. */
function computeFlight(t: number): FlightParcel[] {
  const out: FlightParcel[] = [];
  const maxK = Math.floor(t / SPAWN_INTERVAL_MS);
  for (let k = Math.max(0, maxK - 4); k <= maxK; k++) {
    const spawnAt = k * SPAWN_INTERVAL_MS;
    if (spawnAt < 0 || spawnAt > t) continue;
    const landAt = landTimeForIndex(k);
    if (t >= landAt) continue;
    out.push({ k, progress: (t - spawnAt) / TRANSIT_MS });
  }
  return out;
}

/**
 * The pile as it should render at clock t: tiles landed in the CURRENT
 * fill cycle (sweepProgress 0, resting), plus — for the first
 * SWEEP_DURATION_MS of a new cycle only — the previous cycle's tiles,
 * staggered mid-slide-away.
 */
function computePile(t: number): PileTile[] {
  if (t < 0) return [];
  const cycleIndex = Math.floor(t / SWEEP_PERIOD_MS);
  const cycleStart = cycleIndex * SWEEP_PERIOD_MS;
  const sinceCycleStart = t - cycleStart;

  const out: PileTile[] = [];

  // current cycle's own landings so far
  const currentMaxK = Math.floor((t - TRANSIT_MS) / SPAWN_INTERVAL_MS);
  for (let k = Math.max(0, currentMaxK - 8); k <= currentMaxK; k++) {
    const landAt = landTimeForIndex(k);
    if (landAt > cycleStart && landAt <= t) {
      out.push({ k, sweepProgress: 0 });
    }
  }

  // previous cycle's tiles, still sliding away in the first
  // SWEEP_DURATION_MS of this cycle
  if (cycleIndex > 0 && sinceCycleStart < SWEEP_DURATION_MS) {
    const prevCycleStart = cycleStart - SWEEP_PERIOD_MS;
    const prevMaxK = Math.floor((cycleStart - TRANSIT_MS) / SPAWN_INTERVAL_MS);
    const prevTiles: number[] = [];
    for (let k = Math.max(0, prevMaxK - 8); k <= prevMaxK; k++) {
      const landAt = landTimeForIndex(k);
      if (landAt > prevCycleStart && landAt <= cycleStart) prevTiles.push(k);
    }
    prevTiles.sort((a, b) => a - b);
    prevTiles.forEach((k, i) => {
      const localStart = i * SWEEP_STAGGER_MS;
      const local = sinceCycleStart - localStart;
      const span = SWEEP_DURATION_MS - localStart;
      const p = span <= 0 ? 1 : Math.max(0, Math.min(1, local / span));
      if (p < 1) out.push({ k, sweepProgress: p });
    });
  }

  return out;
}

function easeOut(p: number): number {
  return 1 - (1 - p) * (1 - p);
}

export interface SpiralChuteAccreteProps {
  /** headline / CTA rendered centered over the chute */
  children?: ReactNode;
  /** extra classes merged onto the root element */
  className?: string;
  /** inline styles merged onto the root element */
  style?: React.CSSProperties;
}

export function SpiralChuteAccrete({
  children,
  className = "",
  style,
}: SpiralChuteAccreteProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

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

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // token fields start empty; nothing paints until readTokens() has run
    // at least once (guarded in draw() below) — closes every path (rAF
    // start, resize, IntersectionObserver resume) that could otherwise
    // paint a literal color before the first token read.
    let mutedColor = "";
    let fgColor = "";
    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      mutedColor = cs.getPropertyValue("--ns-muted").trim();
      fgColor = cs.getPropertyValue("--foreground").trim();
    };

    let w = 0;
    let h = 0;
    let cx = 0;
    let radius = 0;
    let topY = 0;
    let rampHeight = 0;
    let baseX = 0;
    let baseY = 0;
    let parcelSize = 0;
    let pileJitter = 0;
    let sized = false;
    let visible = true;
    let raf = 0;
    let last = 0;
    let t = reduced ? STATIC_T : WARM_START_MS;

    // cached ramp path samples, rebuilt only on resize (geometry never
    // changes per frame — only scale/position do).
    let rampPath: Path2D | null = null;

    const pointOnHelix = (theta: number) => {
      const x = cx + radius * Math.sin(theta);
      const y = topY + (theta / THETA_MAX) * rampHeight;
      return [x, y] as const;
    };

    const buildRampPath = () => {
      const path = new Path2D();
      for (let i = 0; i <= RAMP_SAMPLES; i++) {
        const theta = (i / RAMP_SAMPLES) * THETA_MAX;
        const [x, y] = pointOnHelix(theta);
        if (i === 0) path.moveTo(x, y);
        else path.lineTo(x, y);
      }
      rampPath = path;
      const [bx, by] = pointOnHelix(THETA_MAX);
      baseX = bx;
      baseY = by;
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) {
        sized = false;
        return;
      }
      const isCard = !!canvas.closest("[data-autoplay-root]");
      const dpr = isCard
        ? Math.min(0.75, window.devicePixelRatio || 1)
        : Math.min(window.devicePixelRatio || 1, 1.5);
      w = rect.width;
      h = rect.height;
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

      const minDim = Math.min(w, h);
      cx = w / 2;
      // clamp against w/2 as well as min(w,h) — a container taller than
      // wide would otherwise let the helix clip both side edges.
      radius = Math.min(RADIUS_FACTOR * minDim, w * 0.4);
      topY = h * RAMP_TOP_FRAC;
      rampHeight = h * RAMP_HEIGHT_FRAC;
      parcelSize = Math.max(3, minDim * PARCEL_SIZE_FRAC);
      pileJitter = minDim * PILE_JITTER_FRAC;
      sized = true;
      buildRampPath();
    };

    const drawSquare = (x: number, y: number, size: number, alpha: number) => {
      ctx.globalAlpha = alpha;
      ctx.fillRect(x - size / 2, y - size / 2, size, size);
    };

    const draw = () => {
      if (!sized || !fgColor || !mutedColor || !rampPath) return;
      ctx.clearRect(0, 0, w, h);

      // ramp track: the track, not the subject — low, border-like contrast
      ctx.globalAlpha = 1;
      ctx.strokeStyle = mutedColor;
      // floored well above the raw radius*0.02 scale — at card-preview
      // size (~400px, dpr 0.75) that scale alone thins under 2px and the
      // ramp risks disappearing on a light background, the exact failure
      // the spec calls out. Alpha raised to match; parcels/pile still
      // dominate at 0.92-0.95 --foreground.
      ctx.globalAlpha = 0.55;
      ctx.lineWidth = Math.max(1.5, radius * 0.02);
      ctx.lineJoin = "round";
      ctx.lineCap = "round";
      ctx.stroke(rampPath);

      // pile: current cycle's tiles plus any mid-sweep leftovers
      ctx.fillStyle = fgColor;
      const pile = computePile(t);
      for (const tile of pile) {
        const [ox, oy] = PILE_OFFSETS[((tile.k % 9) + 9) % 9];
        const slideX = tile.sweepProgress > 0 ? easeOut(tile.sweepProgress) * (w * 0.7) : 0;
        const x = baseX + ox * pileJitter + slideX;
        const y = baseY + oy * pileJitter * 0.6;
        const alpha = 0.92 * (1 - tile.sweepProgress);
        if (alpha > 0.01) drawSquare(x, y, parcelSize * 0.9, alpha);
      }

      // in-flight parcels on the helix
      const flight = computeFlight(t);
      for (const parcel of flight) {
        const theta = parcel.progress * THETA_MAX;
        const [x, y] = pointOnHelix(theta);
        drawSquare(x, y, parcelSize, 0.95);
      }

      ctx.globalAlpha = 1;
    };

    const loop = (now: number) => {
      const dt = last ? Math.min(50, now - last) : 1000 / 60;
      last = now;
      t += dt;
      draw();
      if (visible && !document.hidden) raf = requestAnimationFrame(loop);
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const ro = new ResizeObserver(() => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (reduced) draw();
      }, 120);
    });
    ro.observe(root);

    const io = new IntersectionObserver(
      (entries) => {
        visible = entries[0]?.isIntersecting ?? true;
        if (visible && !reduced && !raf) {
          last = 0;
          raf = requestAnimationFrame(loop);
        }
      },
      { threshold: 0 }
    );
    io.observe(root);

    const onVis = () => {
      if (!document.hidden && visible && !reduced && !raf) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced || !raf) draw();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    readTokens();
    resize();

    if (reduced) {
      draw();
    } else {
      raf = requestAnimationFrame(loop);
    }

    return () => {
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  return (
    <div
      ref={rootRef}
      className={`relative isolate min-h-screen w-full overflow-hidden bg-background ${className}`}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="pointer-events-none absolute inset-0 block h-full w-full"
      />
      {children ? (
        <div className="relative z-10 flex min-h-screen w-full flex-col items-center justify-center gap-4 px-6 text-center">
          {children}
        </div>
      ) : null}
    </div>
  );
}
Build spec

Build <SpiralChuteAccrete className? style?><...headline/CTA/></SpiralChuteAccrete> as a full-bleed Canvas 2D background reproducing a real gravity spiral chute: the helical slide sorting facilities and department-store stockrooms use to move parcels/mail sacks between floors under gravity alone, no powered conveyance. GEOMETRY: the helix is a cached Path2D built once per resize from a parametric function evaluated at RAMP_SAMPLES (240) points, x(theta) = cx + radius*sin(theta), y(theta) = topY + (theta/THETA_MAX)*rampHeight, with radius = 0.32*min(width,height) and THETA_MAX = 3.5 full turns (TURNS*2*PI) — the container's SMALLER dimension drives radius so the chute reads correctly at both hero and card scale. Every parcel's on-screen position is this SAME cached function evaluated at the parcel's own progress, never a per-frame re-derivation of the curve. STATE IS A PURE FUNCTION OF ONE CLOCK: rather than mutating a live array of parcel/tile objects, every drawn quantity — which parcels are in flight and at what theta, which tiles are in the current pile, which are mid-sweep-out — is derived directly from the elapsed clock t (ms) and a fixed spawn index k, via spawn(k)=k*SPAWN_INTERVAL_MS and land(k)=spawn(k)+TRANSIT_MS. This is what makes prefers-reduced-motion reproducible: freezing the clock at a named STATIC_T reproduces the exact same layout deterministically, no history to serialize. REAL NUMBERS: TRANSIT_MS is 3600ms for one parcel's FULL 3.5-turn transit — this is one of the rare cases where the real-world rate (roughly 1-3 m/s on a shallow chute) and the legible rate are close, so descent renders near real proportion rather than being artificially decoupled. SPAWN_INTERVAL_MS is a fixed 1300ms cadence, which at a 3600ms transit keeps 2-3 parcels in flight simultaneously, each at a different theta so they never visually overlap on the same winding — per the spec's own kill criteria, THIS simultaneous-parcel count (not a faster descent speed) is the intended fix if the motion ever reads as too sparse to register as alive, because a faster descent would break the close-to-real-world proportion the concept was chosen to preserve. PILE / SWEEP: every landed parcel becomes one pile tile positioned near the helix's base point at a jittered offset drawn from a fixed 9-entry deterministic sequence keyed by k % 9 (never per-frame randomness), so the pile shape is exactly reproducible. Every SWEEP_PERIOD_MS (9000ms, fixed) the chute's current fill cycle ends; the tiles that belong to the cycle which just ended slide off-canvas to one side over SWEEP_DURATION_MS (500ms), staggered SWEEP_STAGGER_MS (30ms) apart, entirely derived from t % SWEEP_PERIOD_MS — this bounds the pile to at most one fill cycle's worth of tiles (about 5 at these rates) so it can never visually breach the canvas edge, which the spec calls out explicitly as a bug-read to avoid. Live mode's clock starts at WARM_START_MS = 5150 rather than 0 — a bare t=0 clock is a near-empty chute (one parcel just spawned, pile empty), which fails the spec's own t0 requirement of 2-3 visible parcels plus a partial pile; WARM_START_MS is a deliberately different phase than the reduced-motion STATIC_T so the two never read as the same frame. TOKENS: the ramp track renders at --ns-muted with globalAlpha 0.55 and a lineWidth floored at 1.5 CSS px (radius*0.02 alone thins under 2px at card-preview scale and risks disappearing on a light background) — it is the track, not the subject, and deliberately sits at a low, --border-like contrast step without literally using --border as a canvas stroke (--border stays a separator-only token per the binding rule). Parcels and pile tiles render at --foreground, both read via getComputedStyle(document.documentElement) at mount and re-read on a MutationObserver watching documentElement's class, closing the mutation-observer/resize/rAF-start paint-before-token-read gap. --ns-accent never appears anywhere in this component; the pile-sweep is this component's one climactic moment and is a plain --foreground fade-and-slide, never accent-tinted. prefers-reduced-motion (and initial mount before any observer fires) freezes the clock at STATIC_T = 6450ms, named MID_DESCENT — chosen 71.7% into a 9s fill cycle so the pile shows 3 of a possible ~5 tiles (roughly half full, neither freshly swept nor about to overflow) while two parcels remain visibly mid-spiral at clearly different theta, the single frame that shows track, motion-implying parcel spacing and an in-progress pile all at once. HOST: DPR capped at 1.5 full-bleed / 0.75 inside an autoplay preview card, ResizeObserver-driven resize (rebuilds the cached ramp Path2D and base landing point), IntersectionObserver (threshold 0) and visibilitychange both pausing the single rAF loop off-viewport or tab-hidden, dt clamped to 50ms per frame to avoid a pile/spawn-count jump after a long background tab stall. No interaction: this is a pure ambient background with no pointer state, per spec. Decorative canvas is aria-hidden and pointer-events-none; children render as real DOM in normal focus order above it, centered, with the same scrim convention as sibling full-bleed hero components. Props: children, className, style.

Props

PropTypeDefaultDescription
children?ReactNodeheadline / CTA rendered centered over the chute
className?stringextra classes merged onto the root element
style?React.CSSPropertiesinline styles merged onto the root element