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.

Usage

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

import { SpiralChuteAccrete } from "./component";

export default function SpiralChuteAccreteDemo() {
  return (
    <main className="h-screen w-full overflow-hidden bg-background">
      <SpiralChuteAccrete>
        <div className="pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center gap-4 px-6 pt-14 text-center sm:pt-20">
          <div className="flex flex-col items-center gap-3 rounded-lg bg-background/70 px-7 py-5 backdrop-blur-md">
            <p className="font-mono text-[11px] uppercase tracking-[0.28em] text-ns-muted">
              ns-ui / spiral-chute-accrete
            </p>
            <h1 className="max-w-sm text-sm text-foreground sm:text-base">
              Parcels drop, spiral down, and pile up until the next sweep.
            </h1>
          </div>
          <a
            href="#docs"
            className="pointer-events-auto mt-1 inline-flex w-fit items-center justify-center rounded-sm bg-ns-accent px-5 py-2.5 text-sm font-medium text-white transition-colors duration-150 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          >
            Read the docs
          </a>
        </div>
      </SpiralChuteAccrete>
    </main>
  );
}

Component

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

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/spiral-chute-accrete.tsx, so point the import there.

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.

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?ReactNode—headline / CTA rendered centered over the chute
className?string—extra classes merged onto the root element
style?React.CSSProperties—inline styles merged onto the root element