Skip to main content

ns-ui

Jacquard Card Chain

An ambient status strip modelled on a Jacquard loom's control mechanism: a chain of punched cards feeds past a fixed needle bank, one card read every beat, the bank rippling to the new pattern and holding before the next card slides in, looping forever.

Use when a small ambient card whose backing texture should read as a mechanical program continuously executing (a chain in motion, not a data grid) — a needle bank resolving a new binary pattern once per card read, holding, then sliding to the next card, forever. Pick punch-patch instead when the surface is an actual role x permission matrix that should render as a static punched/patched Jacquard card grid representing real grant state, not a looping ambient process; pick peen-coverage instead when the texture should read as a stochastic, uniform-random coverage process (no ordered rows, no fixed read cadence, no discrete pattern) rather than a deterministic mechanical read cycle.

Install

npx shadcn add https://design.helpmarq.com/r/jacquard-card-chain.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/core/jacquard-card-chain/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// JacquardCardChain — an ambient status/activity strip modelled on the
// Jacquard loom's control mechanism, not a spinner or an indeterminate bar.
// A chain of punched cards, laced end to end into a loop, feeds over a
// rotating cylinder that presses each card against a bank of spring-loaded
// needles: where the card has a hole, its needle passes through and the
// corresponding hook stays engaged; where the card is solid, the needle is
// pushed back and its hook disengages. The chain is a literal physical
// program that loops forever, repeating (in the real machine) as long as
// the loom runs.
//
// This deliberately does NOT render as a static hole/patch data grid (that
// territory belongs to punch-patch's permissions matrix) — the load-bearing
// part here is the CHAIN IN MOTION: cards continuously advance past a fixed
// read gate, a needle bank ripples to the new card's pattern, holds, then
// the next card slides in. Real jacquard read rates (100-1000 picks/min)
// sit at or above the 60Hz paint rate, so per the round-9 legibility rule
// the whole read-and-settle cycle is decoupled to one legible event every
// 900ms rather than animated 1:1: a 16-needle-wide ripple resolves over
// ~160ms (staggered ~4ms/needle so it reads as a wave, not a snap), holds
// for the remainder of the card's dwell, then a 220ms card slide gives a
// clear departure/arrival before the next read begins — never a blink.
//
// Card patterns are generated by a small integer hash keyed on (needle
// index, card index) rather than Math.random(), so the sequence is
// deterministic and never literally repeats card-to-card while staying
// stable across re-renders and reduced-motion freezes.
//
// Card body is drawn with a low-alpha --ns-muted fill standing in for card
// stock (never a --border fill — --border is used only as the thin outline
// stroke and the fixed read-gate guide line, its correct use as a separator
// token) with holes cut through to solid --background so the punched
// pattern reads as negative space rather than an inked colour, in both
// themes. Needles: extended (hole present) draws at --foreground, retracted
// (no hole) at --ns-muted, a shorter, lower-contrast peg. No --ns-accent
// anywhere in the mechanism — this is ambient, not interaction chrome.
// ---------------------------------------------------------------------------

export interface JacquardCardChainProps {
  /** card heading */
  title?: string;
  /** card body copy */
  description?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** inline styles merged onto the root element */
  style?: CSSProperties;
}

const CARD_MS = 900; // one full card read-and-settle cycle
const RIPPLE_MS = 160; // window in which the needle bank resolves to the new pattern
const SLIDE_MS = 220; // card-to-card slide transition (departure + arrival)
const HOLD_END_MS = CARD_MS - SLIDE_MS; // 680 — hold ends, next card's slide begins
const NEEDLE_STAGGER_MS = 4; // per-needle start delay across the bank
const NEEDLE_TRAVEL_MS = 100; // one needle's own travel+settle duration

// Reduced motion freezes mid-hold (not mid-ripple, not mid-slide) on a card
// with a roughly mixed pattern — the most structured single frame.
const FREEZE_PHASE = "CARD_READ_HOLD";
const FREEZE_CARD_INDEX = 2;
const FREEZE_LOCAL_T = 400; // inside the 160-680ms hold window

function easeOutBack(t: number): number {
  const c1 = 1.70158;
  const c3 = c1 + 1;
  const x = t - 1;
  return 1 + c3 * x * x * x + c1 * x * x;
}

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

function clamp01(v: number): number {
  return v < 0 ? 0 : v > 1 ? 1 : v;
}

// deterministic pseudo-random bit for (needle index i, card index k) —
// stable forever, never repeats identically across nearby card indices.
function patternBit(i: number, k: number): 0 | 1 {
  let h = (i * 2654435761 + k * 40503 + 12345) >>> 0;
  h = Math.imul(h ^ (h >>> 15), h | 1);
  h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
  h = (h ^ (h >>> 14)) >>> 0;
  return h % 100 < 50 ? 1 : 0;
}

export function JacquardCardChain({
  title = "Weaving pattern",
  description = "A chain of punched cards feeds past a needle bank, one read every beat — the program loops forever.",
  className = "",
  style,
}: JacquardCardChainProps) {
  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 mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;

    let bg = "#0a0a0a";
    let fg = "#ededed";
    let muted = "#8f8f8f";
    let border = "#2e2e2e";
    const deriveColors = () => {
      const cs = getComputedStyle(document.documentElement);
      bg = cs.getPropertyValue("--background").trim() || bg;
      fg = cs.getPropertyValue("--foreground").trim() || fg;
      muted = cs.getPropertyValue("--ns-muted").trim() || muted;
      border = cs.getPropertyValue("--border").trim() || border;
    };
    deriveColors();

    let w = 0;
    let h = 0;
    let dpr = 1;
    let visible = true;
    let raf = 0;
    let startAt = 0; // performance.now() at first paint of this mount

    const drawFrame = (elapsedMs: number) => {
      if (w <= 0 || h <= 0) return;

      // mechanism occupies the lower ~58% of the card so it never collides
      // with the heading/caption text stacked in the top padding above it.
      const visualTop = h * 0.42;
      const needleZoneH = h * 0.34; // vertical room the needle bank travels within
      const needleBaseY = visualTop + needleZoneH; // baseline needles rise from
      const chainTop = needleBaseY + h * 0.06;
      const chainH = h * 0.18;
      const cardH = chainH * 0.9;
      const readGateX = w * 0.42;
      const cardW = Math.max(24, w * 0.34);

      const needleCount = Math.max(12, Math.min(20, Math.round(cardW / 9)));
      const pitch = cardW / (needleCount + 1);
      const needleStartX = readGateX - ((needleCount - 1) * pitch) / 2;
      const extendedLen = needleZoneH * 0.85;
      const retractedLen = needleZoneH * 0.38;

      const cardIndex = Math.floor(elapsedMs / CARD_MS);
      const localT = elapsedMs - cardIndex * CARD_MS;
      const prevPatternIndex = cardIndex - 1;
      const currPatternIndex = cardIndex;

      const slideT =
        localT < HOLD_END_MS ? 0 : easeOutCubic((localT - HOLD_END_MS) / SLIDE_MS);

      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.fillStyle = bg;
      ctx.fillRect(0, 0, w, h);

      // -- read-gate guide line: --border is a separator token, correct use --
      ctx.strokeStyle = border;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(readGateX, needleBaseY - extendedLen - 4);
      ctx.lineTo(readGateX, chainTop + chainH);
      ctx.stroke();

      // -- card chain, clipped to its lane so cards entering/leaving the
      //    canvas edges are simply cropped by the window, no fade needed --
      ctx.save();
      ctx.beginPath();
      ctx.rect(0, chainTop, w, chainH);
      ctx.clip();

      for (let k = cardIndex - 1; k <= cardIndex + 2; k++) {
        const posUnits = k - cardIndex - slideT;
        const centerX = readGateX + posUnits * cardW;
        if (centerX < -cardW || centerX > w + cardW) continue;

        const left = centerX - cardW / 2 + 2;
        const right = centerX + cardW / 2 - 2;
        const top = chainTop + (chainH - cardH) / 2;

        ctx.globalAlpha = 0.35;
        ctx.fillStyle = muted;
        ctx.fillRect(left, top, right - left, cardH);
        ctx.globalAlpha = 1;

        ctx.strokeStyle = border;
        ctx.lineWidth = 1;
        ctx.strokeRect(left + 0.5, top + 0.5, right - left - 1, cardH - 1);

        const holeR = Math.max(1.5, pitch * 0.26);
        for (let i = 0; i < needleCount; i++) {
          if (patternBit(i, k) !== 1) continue;
          const hx = needleStartX + i * pitch;
          if (hx < left + holeR || hx > right - holeR) continue;
          ctx.beginPath();
          ctx.fillStyle = bg;
          ctx.arc(hx, top + cardH / 2, holeR, 0, Math.PI * 2);
          ctx.fill();
        }
      }
      ctx.restore();

      // -- needle bank: ripple from the previous card's pattern to the
      //    current one, staggered per needle, then hold --
      for (let i = 0; i < needleCount; i++) {
        const startDelay = i * NEEDLE_STAGGER_MS;
        const needleLocalT = localT - startDelay;
        let progress: number;
        if (needleLocalT <= 0) progress = 0;
        else if (needleLocalT >= NEEDLE_TRAVEL_MS) progress = 1;
        else progress = easeOutBack(clamp01(needleLocalT / NEEDLE_TRAVEL_MS));

        const prevBit = patternBit(i, prevPatternIndex);
        const currBit = patternBit(i, currPatternIndex);
        const prevLen = prevBit === 1 ? extendedLen : retractedLen;
        const currLen = currBit === 1 ? extendedLen : retractedLen;
        const len = prevLen + (currLen - prevLen) * clamp01(progress);
        const extended = len > (extendedLen + retractedLen) / 2;

        const nx = needleStartX + i * pitch;
        ctx.strokeStyle = extended ? fg : muted;
        ctx.lineWidth = extended ? 2 : 1.5;
        ctx.beginPath();
        ctx.moveTo(nx, needleBaseY);
        ctx.lineTo(nx, needleBaseY - Math.max(retractedLen * 0.6, len));
        ctx.stroke();
      }
    };

    const loop = (now: number) => {
      if (startAt === 0) startAt = now;
      drawFrame(now - startAt);
      if (!reduced && visible) raf = requestAnimationFrame(loop);
      else raf = 0;
    };
    const wake = () => {
      if (raf === 0 && !reduced && visible) raf = requestAnimationFrame(loop);
    };

    const resize = () => {
      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));

      if (reduced) {
        drawFrame(FREEZE_CARD_INDEX * CARD_MS + FREEZE_LOCAL_T);
      } else {
        drawFrame(startAt === 0 ? 0 : performance.now() - startAt);
      }
    };

    resize();
    if (!reduced) wake();

    const ro = new ResizeObserver(resize);
    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();
      if (reduced) drawFrame(FREEZE_CARD_INDEX * CARD_MS + FREEZE_LOCAL_T);
      else drawFrame(startAt === 0 ? 0 : performance.now() - startAt);
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const onReducedChange = () => {
      reduced = mq.matches;
      if (reduced) {
        cancelAnimationFrame(raf);
        raf = 0;
        drawFrame(FREEZE_CARD_INDEX * CARD_MS + FREEZE_LOCAL_T);
      } else {
        startAt = 0;
        wake();
      }
    };
    mq.addEventListener("change", onReducedChange);

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

    return () => {
      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-jacquard-card-chain relative w-full max-w-sm min-h-[180px] 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 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>
      </div>
    </div>
  );
}

JacquardCardChain.displayName = "JacquardCardChain";

export default JacquardCardChain;
Build spec

Build a card whose entire backing surface is a canvas-rendered Jacquard loom card-chain reader, sourced from the real Jacquard control mechanism, not a decorative punch-card grid. 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 caption, both non-interactive). The mechanism occupies the lower ~58% of the canvas (visualTop = h*0.42 downward) so it never collides with the heading/caption padded at the top — never derive the mechanism's vertical placement from the full card height, always reserve that top margin first. Needle count is derived from the card's own geometry: cardW = w*0.34 (a card is roughly a third of the strip's width, so ~3 cards are visible at once), needleCount = clamp(round(cardW/9), 12, 20), pitch = cardW/(needleCount+1), all needles centred on a FIXED read-gate x position at w*0.42 that never moves — only the chain moves under it. Card-chain motion: cardIndex = floor(elapsedMs/900) where elapsedMs is real wall-clock time since first paint (accumulate via performance.now() deltas in the rAF loop, never frame-count based). localT = elapsedMs - cardIndex*900. For localT < 680ms the card at the gate is held in place (slideT = 0); for localT in [680, 900) slideT eases 0->1 via easeOutCubic, and every visible card's on-screen x position is `readGateX + (k - cardIndex - slideT) * cardW` for card index k, so the whole chain glides left by exactly one card width over that 220ms window before the next 900ms cycle begins with the next card now at the gate — draw cards for k in [cardIndex-1, cardIndex+2] and clip the draw to the chain's horizontal lane so cards are simply cropped at the canvas edges rather than needing a fade mask. Each card's punched pattern is generated by a small deterministic integer hash keyed on (needle index i, card index k) — never Math.random() — so patterns are stable and never require storing history, and the SAME hash function is used both for a card's drawn holes and for what the needle bank reads when that card is at the gate, keeping the two visually consistent. Needle bank: for each needle i, the displayed peg length lerps from the PREVIOUS card's bit value (patternBit(i, cardIndex-1)) to the CURRENT card's bit value (patternBit(i, cardIndex)) over a per-needle window that starts at `i * 4ms` into the current 900ms cycle and runs for 100ms, eased with a small overshoot (critically-damped-spring-style, e.g. an easeOutBack curve) so the bank resolves as a left-to-right ripple rather than a simultaneous snap — this is the load-bearing round-9 legibility fix: real jacquard read rates (100-1000 picks/min) sit at or above the page's paint rate, so the read is decoupled to one legible 900ms cycle (a ~160ms ripple, a hold, then a 220ms card slide) instead of animated 1:1. A bit value of 1 (hole present) draws a LONGER peg at --foreground; 0 (no hole) draws a SHORTER peg at --ns-muted — extended/retracted, never a colour swap. Card body: filled with --ns-muted at globalAlpha 0.35 standing in for card stock (never --border as a fill), outlined with a 1px --border stroke (correct separator use). Holes are literal cut-throughs: for every needle position where that card's pattern bit is 1, fill a small circle (radius ~0.26*pitch) with solid --background, punching through the muted card fill so the pattern reads as negative space rather than an inked colour, identically in both themes. A single 1px --border read-gate guide line is drawn top-to-bottom at the fixed gate x — also a legitimate separator use, giving the eye a fixed anchor to judge the chain's motion against. Colour is read via getComputedStyle(document.documentElement) for --background, --foreground, --ns-muted, --border at mount and re-derived on a MutationObserver watching documentElement's class — never a literal, and the canvas is explicitly filled with --background every frame rather than relying on transparency, so the punched holes read correctly regardless of what sits behind the card in the page. No --ns-accent anywhere — this is ambient chrome, not interaction. DPR-capped (max 2) backing store sized off the card's own getBoundingClientRect via ResizeObserver. The render loop pauses via IntersectionObserver (not scrolled into view) and visibilitychange (tab hidden), and resumes with elapsed time still measured from the original mount timestamp so the chain never jumps or resets on resume. Under prefers-reduced-motion: reduce, the entire loop never starts; a single synchronous frame is drawn at a fixed elapsedMs corresponding to cardIndex=2, localT=400ms (named CARD_READ_HOLD, exposed as data-reduced-motion-freeze on the root) — a card fully resolved and held (not mid-ripple, not mid-slide) with a roughly mixed pattern, dense enough to read as structured without any motion. No interaction and no dependencies; the component is fully ambient/autoplay by design, so autoplay.mode is "none" — there is nothing for the site's synthetic-input driver to trigger.

Props

PropTypeDefaultDescription
title?string"Weaving pattern"card heading
description?string"A chain of punched cards feeds past a needle bank, one read every beat — the program loops forever."card body copy
className?stringextra classes merged onto the rendered root element
style?CSSPropertiesinline styles merged onto the root element