Skip to main content

ns-ui

Tufting Gun Loop Pile

A card whose backing texture is a genuine tufting-gun loop-pile build: a carriage sweeping row by row across a backing grid, plunging a loop of pile at each cell just behind it, the finished field feeding continuously off the top edge as new rows tuft in below.

Usage

registry/core/tufting-gun-loop-pile/demo.tsx
"use client";

import { TuftingGunLoopPile } from "./component";

export default function TuftingGunLoopPileDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-10">
      <TuftingGunLoopPile className="min-h-[280px]" />
    </main>
  );
}

Component

registry/core/tufting-gun-loop-pile/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// TuftingGunLoopPile — an ambient card texture reproducing a tufting gun's
// row-by-row loop-pile build (hand/machine tufting gun operation, carpet
// manufacture), not a generic "loading tiles" fill. A hollow needle plunges
// straight down through a backing cloth, grips a strand of yarn, withdraws
// leaving a loop of pile standing on the front face, the carriage then
// advances one fixed pitch along the row and repeats. Real guns run
// 800-1500 punches/minute per needle; per the round 9 decoupling rule that
// rate is never animated 1:1 — a single tuft "pop" (backing -> filled loop)
// is fixed at 90ms regardless of gun cadence, fast enough to read as a
// discrete event without simultaneous rows and without aliasing against the
// paint rate.
//
// The active row's carriage crosses the row in a fixed 1100ms (per-cell
// interval = 1100 / cols, so a wider card gets a faster per-cell tick but
// the SAME row-crossing time — the ~24-36 cells/s spec figure falls out of
// that division rather than being hand-tuned per width) with a crosshair
// drawn at --foreground continuously interpolated across the row so the eye
// can track carriage position at any instant, cells popping in just behind
// it. Once every cell in the active row is filled, the whole tufted field
// scrolls up by exactly one row-pitch over a 260ms ease — old rows climbing
// toward the top edge, a fixed strip of not-yet-reached rows always resting
// below the active slot — then the field settles, a new row starts empty at
// the same active slot, and the cycle repeats. This never stops or resets:
// the row history is uncapped in principle, only culled from the draw loop
// once it has scrolled fully above the canvas, which is why the specific
// filled/empty pattern visible at t0 is guaranteed to have scrolled
// entirely off-frame by t=5s.
//
// Geometry is derived from the container's own smaller dimension: cell
// pitch = min(width,height)/26, clamped to [6,14]px (so a 240px-tall card
// lands on the spec's ~9px pitch and ~26 rows), and the column count is
// sized a little past width/pitch so the field overhangs both edges rather
// than ending on a half-cell.
// ---------------------------------------------------------------------------

export interface TuftingGunLoopPileProps {
  /** card heading */
  title?: string;
  /** card body copy */
  description?: string;
  /** trailing link label; omit to render the card with no link */
  linkLabel?: string;
  /** link href, used only when linkLabel is set */
  href?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** inline styles merged onto the root element */
  style?: CSSProperties;
}

type RGB = [number, number, number];

function parseColor(raw: string): RGB | null {
  const s = raw.trim();
  if (s.startsWith("#")) {
    let hex = s.slice(1);
    if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
    if (hex.length < 6) return null;
    const r = parseInt(hex.slice(0, 2), 16);
    const g = parseInt(hex.slice(2, 4), 16);
    const b = parseInt(hex.slice(4, 6), 16);
    return Number.isNaN(r + g + b) ? null : [r, g, b];
  }
  const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

function mixRGB(a: RGB, b: RGB, t: number): RGB {
  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}

function relLuminance([r, g, b]: RGB): number {
  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}

/** Nudge a token's own channels up by a fixed delta (never a colour
 * literal blended in) — the additive analogue of "lighter", stays legible
 * even where the source is already near-black in light theme. */
function lighten(c: RGB, delta: number): RGB {
  return [Math.min(255, c[0] + delta), Math.min(255, c[1] + delta), Math.min(255, c[2] + delta)];
}

function rgbStr([r, g, b]: RGB, alpha = 1): string {
  return `rgba(${r.toFixed(0)}, ${g.toFixed(0)}, ${b.toFixed(0)}, ${alpha})`;
}

function easeOutCubic(t: number): number {
  const p = 1 - t;
  return 1 - p * p * p;
}

const PITCH_DIVISOR = 26; // cell pitch = min(w,h) / 26 — a 240px-tall card lands on ~9px, ~26 rows
const PITCH_MIN = 6;
const PITCH_MAX = 14;
const ROW_SWEEP_MS = 1100; // fixed row-crossing time regardless of column count
const POP_MS = 90; // single tuft backing -> filled-loop pop, decoupled from real gun cadence
const SCROLL_MS = 260; // field feed after a row completes
const EMPTY_ROWS_BELOW = 2; // fixed strip of not-yet-reached backing kept below the active row
const FIELD_OVERHANG_CELLS = 2; // extra columns so the field overhangs both edges

type Phase = "sweeping" | "scrolling";

// Reduced motion freezes with the head paused ~50% through its row, a
// handful of completed rows above and empty backing weave below — the most
// structured single frame (mixed filled/unfilled state, head mid-sweep, not
// at a row boundary). Named GUN_MIDROW, exposed via data-reduced-motion-freeze.
const FREEZE_PHASE = "GUN_MIDROW";
const FREEZE_FILLED_FRAC = 0.5;
const FREEZE_COMPLETED_ROWS = 4;

export function TuftingGunLoopPile({
  title = "Backing, row 1,842",
  description = "The carriage plunges a loop, advances one pitch, and the field feeds beneath it — the pile never stops building.",
  linkLabel = "Read the process card",
  href = "#",
  className = "",
  style,
}: TuftingGunLoopPileProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);

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

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

    // -- token-derived colour, re-read on any theme class flip, never a
    // literal (not even a fallback) — nothing paints until this has run
    // successfully once. --
    let fg: RGB | null = null;
    let border: RGB | null = null;
    let rim: RGB | null = null;
    let colorsReady = false;
    const deriveColors = () => {
      const cs = getComputedStyle(document.documentElement);
      const nextFg = parseColor(cs.getPropertyValue("--foreground"));
      const nextBg = parseColor(cs.getPropertyValue("--background"));
      const nextBorder = parseColor(cs.getPropertyValue("--border"));
      const nextMuted = parseColor(cs.getPropertyValue("--ns-muted"));
      if (!nextFg || !nextBg || !nextBorder) return;
      fg = nextFg;
      // rim: the same --foreground token nudged up by a fixed additive
      // delta, luminance only, never --ns-accent — sells the loop's
      // rounded top catching light.
      rim = lighten(nextFg, 46);
      // --border is a separator token at ~1.1:1 contrast in light theme —
      // as a crosshatch fill it would be unreadable there, so in light
      // theme it is mixed toward --ns-muted (same precedent as the
      // peen-coverage sibling's light-theme branch) rather than used raw.
      const isDark = relLuminance(nextBg) < 0.5;
      border = isDark || !nextMuted ? nextBorder : mixRGB(nextBorder, nextMuted, 0.7);
      colorsReady = true;
    };
    deriveColors();

    let w = 0;
    let h = 0;
    let dpr = 1;
    // derived from the container's own smaller dimension every layout pass
    let CELL_PITCH = PITCH_MIN;
    let cols = 1;
    let fieldW = 0;
    let fieldOffsetX = 0;
    let activeY = 0; // fixed canvas y (top edge) of the active row's slot
    let visible = true;
    let raf = 0;

    let phase: Phase = "sweeping";
    let filled = 0; // cells confirmed filled in the active row
    let rowElapsed = 0; // ms into the current row's sweep
    let scrollElapsed = 0; // ms into the current scroll transition
    let completedRows = 0; // count of fully-filled rows resting above the active slot
    let lastNow = 0;

    const maxCompletedRows = () => Math.ceil(activeY / CELL_PITCH) + 3;

    const cellInterval = () => ROW_SWEEP_MS / Math.max(1, cols);

    const resetRow = () => {
      filled = 0;
      rowElapsed = 0;
      phase = "sweeping";
    };

    const advanceSweep = (dtMs: number) => {
      rowElapsed = Math.min(ROW_SWEEP_MS + POP_MS, rowElapsed + dtMs);
      const target = Math.min(cols, Math.floor(rowElapsed / cellInterval()));
      if (target > filled) filled = target;
      // wait for the LAST cell's own 90ms pop to finish, not just the
      // sweep clock, so the row never snaps its final cells into place.
      if (filled >= cols && rowElapsed >= ROW_SWEEP_MS + POP_MS) {
        phase = "scrolling";
        scrollElapsed = 0;
      }
    };

    const advanceScroll = (dtMs: number) => {
      scrollElapsed = Math.min(SCROLL_MS, scrollElapsed + dtMs);
      if (scrollElapsed >= SCROLL_MS) {
        completedRows = Math.min(maxCompletedRows(), completedRows + 1);
        resetRow();
      }
    };

    // -- draw a faint crosshatch backing weave for an unfilled cell,
    // --border used correctly as a separator, never a fill of solid shapes --
    const drawBacking = (x: number, y: number) => {
      if (!border) return;
      ctx.beginPath();
      ctx.moveTo(x + 1, y + 1);
      ctx.lineTo(x + CELL_PITCH - 1, y + CELL_PITCH - 1);
      ctx.moveTo(x + CELL_PITCH - 1, y + 1);
      ctx.lineTo(x + 1, y + CELL_PITCH - 1);
      ctx.strokeStyle = rgbStr(border, 0.9);
      ctx.lineWidth = 1;
      ctx.stroke();
    };

    // -- a filled loop: a short vertical ellipse in --foreground with a
    // lighter rim arc along its top-left, selling a rounded pile top --
    const drawLoop = (x: number, y: number, popT: number) => {
      if (!fg || !rim) return;
      const cx = x + CELL_PITCH / 2;
      const cy = y + CELL_PITCH / 2;
      const rx = (CELL_PITCH * 0.34) * popT;
      const ry = (CELL_PITCH * 0.42) * popT;
      if (rx <= 0.2 || ry <= 0.2) return;
      ctx.beginPath();
      ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
      ctx.fillStyle = rgbStr(fg, 1);
      ctx.fill();
      ctx.beginPath();
      ctx.ellipse(cx - rx * 0.28, cy - ry * 0.32, rx * 0.55, ry * 0.4, -0.6, 0, Math.PI * 2);
      ctx.strokeStyle = rgbStr(rim, 0.85);
      ctx.lineWidth = 1;
      ctx.stroke();
    };

    const drawHead = (x: number, y: number) => {
      if (!fg) return;
      const cx = x;
      const cy = y + CELL_PITCH / 2;
      const arm = CELL_PITCH * 0.75;
      ctx.strokeStyle = rgbStr(fg, 1);
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.moveTo(cx - arm, cy);
      ctx.lineTo(cx + arm, cy);
      ctx.moveTo(cx, cy - arm);
      ctx.lineTo(cx, cy + arm);
      ctx.stroke();
    };

    const draw = () => {
      if (w <= 0 || h <= 0 || !colorsReady) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);

      const scrollT = phase === "scrolling" ? easeOutCubic(scrollElapsed / SCROLL_MS) : 0;
      const scrollPx = scrollT * CELL_PITCH;

      // completed rows, oldest at the top, scrolling up together with the
      // in-progress transition; culled once fully above the canvas top
      for (let n = 1; n <= completedRows; n++) {
        const y = activeY - n * CELL_PITCH + scrollPx;
        if (y + CELL_PITCH < -CELL_PITCH) continue;
        if (y > h) continue;
        for (let c = 0; c < cols; c++) {
          drawLoop(fieldOffsetX + c * CELL_PITCH, y, 1);
        }
      }

      // active row, still mid-sweep or mid-scroll (fully filled, sliding
      // toward the "completed" slot one pitch above)
      {
        const y = activeY + scrollPx;
        for (let c = 0; c < cols; c++) {
          const x = fieldOffsetX + c * CELL_PITCH;
          if (c < filled) {
            const cellStart = c * cellInterval();
            const popT = phase === "scrolling" ? 1 : Math.min(1, (rowElapsed - cellStart) / POP_MS);
            drawLoop(x, y, Math.max(0, popT));
          } else {
            drawBacking(x, y);
          }
        }
        // the head stays visible through the scroll transition too, riding
        // up with the row it just finished — round 9's rule that a
        // discrete transition must show departure and arrival, never a
        // blink, applies to the head as much as to the cells.
        const headFrac = phase === "sweeping" ? Math.min(1, rowElapsed / ROW_SWEEP_MS) : 1;
        const headX = fieldOffsetX + headFrac * cols * CELL_PITCH;
        drawHead(headX, y);
      }

      // fixed strip of not-yet-reached backing rows, always resting below
      // the active slot — these never scroll, the active slot is pinned
      for (let n = 1; n <= EMPTY_ROWS_BELOW; n++) {
        const y = activeY + n * CELL_PITCH;
        if (y > h) continue;
        for (let c = 0; c < cols; c++) {
          drawBacking(fieldOffsetX + c * CELL_PITCH, y);
        }
      }
    };

    const loop = (now: number) => {
      const dt = lastNow === 0 ? 0 : Math.min(100, now - lastNow);
      lastNow = now;
      if (phase === "sweeping") advanceSweep(dt);
      else advanceScroll(dt);
      draw();
      if (!reduced && visible) raf = requestAnimationFrame(loop);
      else raf = 0;
    };
    const wake = () => {
      if (raf === 0 && !reduced && visible) {
        lastNow = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    let hasSeeded = false;

    const layout = () => {
      const rect = root.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      if (w < 2 || h < 2) return;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));

      CELL_PITCH = Math.min(PITCH_MAX, Math.max(PITCH_MIN, Math.min(w, h) / PITCH_DIVISOR));
      cols = Math.max(4, Math.ceil(w / CELL_PITCH) + FIELD_OVERHANG_CELLS);
      fieldW = cols * CELL_PITCH;
      fieldOffsetX = -(fieldW - w) / 2;

      const activeSlotFromBottom = EMPTY_ROWS_BELOW + 1;
      activeY = h - activeSlotFromBottom * CELL_PITCH;

      if (reduced) {
        phase = "sweeping";
        scrollElapsed = 0;
        completedRows = Math.min(maxCompletedRows(), FREEZE_COMPLETED_ROWS);
        filled = Math.round(cols * FREEZE_FILLED_FRAC);
        rowElapsed = filled * cellInterval() + POP_MS; // every filled cell's pop finished
        draw();
        return;
      }

      if (!hasSeeded) {
        // Seed the resting loop already mid-flight instead of blank: t0 is
        // "head mid-row, roughly a third filled, several completed rows
        // above" per spec, not an empty card that takes ~30s to fill in.
        hasSeeded = true;
        completedRows = maxCompletedRows();
        filled = Math.max(1, Math.round(cols / 3));
        rowElapsed = filled * cellInterval();
        phase = "sweeping";
      } else {
        // a genuine resize (not the resting loop) — keep progress, just
        // clamp it to the new geometry rather than blanking the field.
        completedRows = Math.min(completedRows, maxCompletedRows());
        filled = Math.min(filled, cols);
        rowElapsed = Math.min(rowElapsed, ROW_SWEEP_MS + POP_MS);
      }
      draw();
    };

    layout();
    if (!reduced) wake();

    const ro = new ResizeObserver(layout);
    ro.observe(root);

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible) wake();
      else if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    });
    io.observe(root);

    const mo = new MutationObserver(() => {
      deriveColors();
      draw();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const onReducedChange = () => {
      reduced = mq.matches;
      cancelAnimationFrame(raf);
      raf = 0;
      layout();
      if (!reduced) wake();
    };
    mq.addEventListener("change", onReducedChange);

    const onVisibility = () => {
      if (document.visibilityState === "visible") {
        wake();
      } else if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    };
    document.addEventListener("visibilitychange", onVisibility);

    let disposed = false;
    document.fonts.ready.then(() => {
      if (!disposed) layout();
    });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      raf = 0;
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      mq.removeEventListener("change", onReducedChange);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  return (
    <div
      ref={rootRef}
      data-reduced-motion-freeze={FREEZE_PHASE}
      className={`ns-tufting-gun-loop-pile relative w-full max-w-sm overflow-hidden rounded-[14px] border border-border bg-background ${className}`}
      style={style}
    >
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full" />
      <div ref={contentRef} className="pointer-events-none relative flex flex-col gap-3 p-6">
        <h3 className="text-balance font-sans text-lg font-medium text-foreground">{title}</h3>
        <p className="text-pretty font-mono text-xs leading-relaxed text-ns-muted">{description}</p>
        {linkLabel ? (
          <a
            href={href}
            className="pointer-events-auto mt-1 inline-flex w-fit items-center gap-1 rounded-sm font-mono text-xs font-medium text-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          >
            {linkLabel}
            <span aria-hidden="true">&rarr;</span>
          </a>
        ) : null}
      </div>
    </div>
  );
}

TuftingGunLoopPile.displayName = "TuftingGunLoopPile";

export default TuftingGunLoopPile;

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

Use when an empty-state or ambient card background that wants ordered, carriage-swept, row-by-row texture that visibly builds forever — a discrete loop-pile field fed by a tracked gun head, never finishing or resetting. Pick peen-coverage instead when the process should read as a stochastic, uniform-random impact stream saturating a coverage percentage with no ordered rows and no swept head; pick screen-flood-stroke instead when a single continuous blade stroke should sweep the whole field once per cycle rather than plunging individually-timed discrete loops at grid cells.

Install

npx shadcn add https://design.helpmarq.com/r/tufting-gun-loop-pile.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 a card whose entire backing surface is a canvas-rendered tufting-gun loop-pile simulation, sourced from real hand/machine tufting gun operation (textile/carpet manufacture), not a generic 'loading tiles' fill. Root is a `rounded-[14px] border border-border bg-background` card holding an absolutely positioned, `aria-hidden`, `pointer-events-none` canvas filling the card behind a `pointer-events-none` content stack (heading, mono body copy, optional underlined link that re-enables `pointer-events-auto` and carries a `focus-visible` ring in `--ns-accent`); the demo wraps it with an explicit minimum height (`min-h-[280px]`) so the field has room to read as a building carpet rather than a sliver behind the copy. Grid geometry is derived from the container's own smaller dimension via ResizeObserver: cell pitch = `clamp(6, min(width,height)/26, 14)` px in both axes (a 240px-tall card lands on the spec's ~9px pitch, ~26 rows), and the column count is `ceil(width/pitch) + 2` so the field overhangs both edges rather than ending on a visible half-cell, centred by offsetting the draw origin by `-(fieldWidth - width)/2`. A hollow-needle gun head sweeps left to right across one 'active' row at a time: the whole row-crossing takes a fixed 1100ms regardless of column count (`cellInterval = 1100 / cols`, so a wider card ticks faster per-cell but crosses the row in the same wall-clock time — the resulting ~24-36 cells/second figure falls out of that division, never hand-tuned per width), with a crosshair drawn at `--foreground` whose x-position is continuously interpolated from elapsed sweep time (not snapped to the cell grid) so a viewer can track the head's position at any instant. Cells behind the head pop from backing to filled loop over a fixed 90ms — decoupled per the round 9 rule from the gun's real 800-1500 punches/minute rate, which would alias against paint; the 90ms pop is a legible discrete event, never simultaneous across the row. The row only hands off to the scroll transition once the LAST cell's own 90ms pop has actually finished (`rowElapsed >= ROW_SWEEP_MS + POP_MS`, not just the sweep clock), so the final cells never snap into place. Once that's true, the whole tufted field scrolls up by exactly one row-pitch over a 260ms ease-out — completed rows climbing toward the top edge, culled from the draw loop once fully above the canvas — then the active row resets empty at the same fixed canvas slot and the next row's sweep begins; this never stops, restarts, or caps, so the specific filled/empty pattern visible at t0 is guaranteed to have scrolled entirely off-frame by t=5s. The gun head crosshair stays visible through the scroll transition too, riding up at the row-end x with the row it just finished, so the handoff shows departure and arrival rather than blinking out for the 260ms (round 9's cadence rule). A fixed strip of `EMPTY_ROWS_BELOW = 2` not-yet-reached backing rows always rests below the active row's slot and never itself scrolls (the active slot is pinned; the field feeds up through it) — this is what reads as 'empty backing weave below the head' in the resting frame. On mount only (not on later resizes), the field is seeded already mid-flight rather than blank: completed rows filled up to the canvas top, the active row a third filled, per the spec's own t0 description — a resize afterward clamps existing progress to the new geometry instead of re-blanking it. Completed and active-row loops are rendered as a short vertical ellipse (`rx = 0.34*pitch, ry = 0.42*pitch` at full pop) filled solid `--foreground`, with a lighter rim arc along the top-left stroked in the SAME `--foreground` token nudged by a fixed additive delta (+46 per channel, clamped to 255) in luminance only — never a literal colour, never `--ns-accent` — to sell the loop's rounded top catching light. Unfilled cells render a faint two-line diagonal crosshatch (a separator token used correctly as a separator, never a fill of solid shapes): in dark theme that's `--border` directly, but `--border` alone is only ~1.1:1 against `--background` in light theme, so in light theme the crosshatch stroke is `--border` mixed 70% toward `--ns-muted` (branch chosen from `--background`'s own relative luminance) to stay legible — light theme was checked first, not as a final pass. Colour is read via `getComputedStyle(document.documentElement)` for `--foreground`, `--background`, `--border` and `--ns-muted` at mount, before any canvas paint (nothing paints, not even a fallback, until that first read resolves all three required tokens), and re-derived on a `MutationObserver` watching `documentElement`'s class. DPR-capped (max 2) backing store sized off the card's own `getBoundingClientRect`, resized on `ResizeObserver` (which also re-derives cell pitch, column count and the active row's fixed y-slot) and re-laid-out once more after `document.fonts.ready` (guarded against firing post-unmount). The render loop pauses via `IntersectionObserver` (not scrolled into view) and `visibilitychange` (tab hidden), and is otherwise a plain `requestAnimationFrame` driven by real elapsed time (never frame-count), so sweep speed and pop/scroll durations hold regardless of frame rate. Interaction: none — this is a genuine unconditional rAF loop with no pointer response and no autoplay-mode dependency, `autoplay.mode` is `"none"` because nothing about it depends on synthetic pointer/press/scroll input. Under `prefers-reduced-motion: reduce`, the loop never starts: layout bakes the head paused 50% through the active row (`filled = round(cols*0.5)`, every one of those cells' 90ms pops already resolved), 4 completed rows resting above it, and the fixed empty strip below, named `GUN_MIDROW` and exposed via `data-reduced-motion-freeze` on the root — the most structured single frame (mixed filled/unfilled state, head visibly mid-sweep, not at a row boundary). No dependencies.

Props

PropTypeDefaultDescription
title?string"Backing, row 1,842"card heading
description?string"The carriage plunges a loop, advances one pitch, and the field feeds beneath it — the pile never stops building."card body copy
linkLabel?string"Read the process card"trailing link label; omit to render the card with no link
href?string"#"link href, used only when linkLabel is set
className?string—extra classes merged onto the rendered root element
style?CSSProperties—inline styles merged onto the root element