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.jsonSource
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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 |