Skip to main content

ns-ui

Knit Ladder Run

An ambient status/health card background: a field of knit stitches builds downward course by course, and at random intervals a dropped stitch opens a ladder that runs a few rows before a latch-hook repair catches it and reknits the column. Structurally sound, with occasional self-corrected faults, in place of a static health badge.

Usage

registry/core/knit-ladder-run/demo.tsx
"use client";

import { KnitLadderRun } from "./component";

export default function KnitLadderRunDemo() {
  return (
    <div
      data-knit-ladder-card
      className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16"
    >
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / knit-ladder-run
      </p>

      <div className="h-72 w-full max-w-sm overflow-hidden rounded-[16px] border border-border bg-background">
        <KnitLadderRun />
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        A knit fabric builds course by course; every so often a dropped stitch opens a ladder that
        a latch-hook repair reknits from the bottom up — structurally sound, with occasional
        self-corrected faults.
      </p>
    </div>
  );
}

Component

registry/core/knit-ladder-run/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// KnitLadderRun — an ambient status/health feedback card background: a field
// of knit stitches builds downward course by course, and at random intervals
// a dropped stitch opens a ladder ("run") that unravels a few rows before a
// latch-hook repair catches it and reknits the column. Source: weft-knit
// loop formation + latch-hook ladder repair (textile/knitting).
//
// Distinct from text-stitch-unpick (pointer-driven, letter-by-letter,
// irreversible per letter) and optimistic-stitch (a single row's write
// lifecycle, never loops): this is fully ambient, builds continuously, and
// its ladder-run is a RECURRING, SELF-HEALING fault, not a one-shot
// lifecycle or a pointer gesture.
//
// TIMELINE — two clocks, both derived from one elapsed-ms counter, no React
// state:
//   continuousCourse = elapsedMs / COURSE_MS   (smooth fractional scroll)
//   currentCourse    = floor(continuousCourse)  (newest row that exists)
// New courses enter at the bottom edge; existing courses drift upward and
// eventually exit the top (the "continuous fabric take-down" feed) — never
// a fixed-length pass.
//
// LADDER STATE MACHINE (one plain ref object, at most one ladder live):
//   idle -> trigger (random 7-10s gap) picks a column, a length (4-7 rows)
//   and an anchor `dropRow` = currentCourse - length (so the whole span
//   already exists and propagation never has to wait on new courses).
//   OPEN phase: row (dropRow + k) collapses from a stitch to a bare rail
//   at openAt(k) = openStartTime + k*350ms, animated over a 120ms window.
//   REPAIR phase starts once every row has opened (repairStart = openStart
//   + length*350ms) and closes rows bottom-up (the newest/last-opened row
//   closes FIRST) one rung every 300ms, each with a brief latch-hook glyph
//   and a 150ms close animation, until the topmost (drop) row reseals and
//   the column matches its neighbors again — the ladder always finishes.
// ---------------------------------------------------------------------------

const COURSE_MS = 900; // one full course (row) build event
const PITCH_TARGET = 10; // px, target stitch pitch
const MIN_COLS = 14;
const MAX_COLS = 28;

const LADDER_MIN_INTERVAL_MS = 7000;
const LADDER_MAX_INTERVAL_MS = 10000;
const LADDER_MIN_LEN = 4;
const LADDER_MAX_LEN = 7;
const OPEN_STEP_MS = 350;
const OPEN_ANIM_MS = 120;
const CLOSE_STEP_MS = 300;
const CLOSE_ANIM_MS = 150;

const AGE_FADE_ROWS = 14; // rows over which a fresh stitch recedes to the muted baseline
const MUTED_BASE_ALPHA = 0.55;
const FRESH_MAX_ALPHA = 0.75; // "moderate", never full-strength per spec

// reduced-motion freeze: a static ladder frozen mid-run, not derived from RNG
const STATIC_CURRENT_COURSE = 16;
const STATIC_DROP_ROW = 11;
const STATIC_LADDER_LEN = 3; // "2-3 rows open"

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

interface LadderState {
  active: boolean;
  column: number;
  dropRow: number;
  length: number;
  openStart: number; // elapsedMs
  repairStart: number; // elapsedMs, derived at trigger time
  nextTriggerAt: number; // elapsedMs
}

export interface KnitLadderRunProps {
  className?: string;
}

export function KnitLadderRun({ className = "" }: KnitLadderRunProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

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

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

    let fg = "currentColor";
    let muted = "currentColor";
    let width = 0;
    let height = 0;
    let dpr = 1;
    let sized = false;
    let visible = true;

    let pitch = PITCH_TARGET;
    let cols = MIN_COLS;

    const ladder: LadderState = {
      active: false,
      column: 0,
      dropRow: 0,
      length: 0,
      openStart: 0,
      repairStart: 0,
      nextTriggerAt: LADDER_MIN_INTERVAL_MS + Math.random() * (LADDER_MAX_INTERVAL_MS - LADDER_MIN_INTERVAL_MS),
    };

    const readTokens = () => {
      const s = getComputedStyle(document.documentElement);
      fg = s.getPropertyValue("--foreground").trim() || "currentColor";
      muted = s.getPropertyValue("--ns-muted").trim() || fg;
    };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      width = rect.width;
      height = rect.height;
      if (width < 2 || height < 2) {
        sized = false;
        return;
      }
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

      cols = Math.min(MAX_COLS, Math.max(MIN_COLS, Math.round(width / PITCH_TARGET)));
      pitch = width / cols;
      sized = true;
    };

    // small interlocked loop glyph — two overlapping arcs, the "stitch" unit
    const drawStitchGlyph = (cx: number, cy: number, color: string, alpha: number) => {
      if (alpha <= 0.01) return;
      const r = pitch * 0.28;
      ctx.globalAlpha = alpha;
      ctx.strokeStyle = color;
      ctx.lineWidth = 1;
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.arc(cx, cy - r * 0.32, r, Math.PI * 0.15, Math.PI * 0.85);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(cx, cy + r * 0.32, r, Math.PI * 1.15, Math.PI * 1.85);
      ctx.stroke();
      ctx.globalAlpha = 1;
    };

    const drawNormalStitch = (cx: number, cy: number, age: number, alphaMul: number = 1) => {
      if (alphaMul <= 0.01) return;
      const fresh = Math.min(1, Math.max(0, 1 - age / AGE_FADE_ROWS));
      drawStitchGlyph(cx, cy, muted, MUTED_BASE_ALPHA * alphaMul);
      if (fresh > 0.01) drawStitchGlyph(cx, cy, fg, fresh * FRESH_MAX_ALPHA * alphaMul);
    };

    // a bare vertical rail marking an open rung, plus an optional latch-hook
    // glyph while that rung is actively closing.
    const drawRail = (cx: number, cy: number, alpha: number) => {
      if (alpha <= 0.01) return;
      ctx.globalAlpha = alpha;
      ctx.strokeStyle = fg;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(cx, cy - pitch / 2);
      ctx.lineTo(cx, cy + pitch / 2);
      ctx.stroke();
      ctx.globalAlpha = 1;
    };

    const drawHookGlyph = (cx: number, cy: number, alpha: number) => {
      if (alpha <= 0.01) return;
      const s = pitch * 0.24;
      ctx.globalAlpha = alpha;
      ctx.strokeStyle = fg;
      ctx.lineWidth = 1.2;
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.moveTo(cx, cy + s);
      ctx.lineTo(cx, cy - s * 0.2);
      ctx.arc(cx - s * 0.35, cy - s * 0.2, s * 0.35, 0, Math.PI * 1.4);
      ctx.stroke();
      ctx.globalAlpha = 1;
    };

    // renders one cell that belongs to the currently active ladder column,
    // returning true if it drew a ladder-specific state (caller skips the
    // normal stitch render in that case).
    const drawLadderCell = (rowIndex: number, cx: number, cy: number, elapsedMs: number, age: number): boolean => {
      if (!ladder.active) return false;
      const rowOffset = rowIndex - ladder.dropRow;
      if (rowOffset < 0 || rowOffset >= ladder.length) return false;

      const openAt = ladder.openStart + rowOffset * OPEN_STEP_MS;
      // rungs close bottom-up: the newest (highest offset) rung closes first
      const closeAt = ladder.repairStart + (ladder.length - 1 - rowOffset) * CLOSE_STEP_MS;

      if (elapsedMs < openAt) {
        drawNormalStitch(cx, cy, age);
        return true;
      }
      if (elapsedMs < openAt + OPEN_ANIM_MS) {
        // the loop glyph visibly collapses to a bare gap: fade the stitch
        // out while the rail fades in over the same 120ms window
        const eased = easeOutCubic((elapsedMs - openAt) / OPEN_ANIM_MS);
        drawNormalStitch(cx, cy, age, 1 - eased);
        drawRail(cx, cy, eased);
        return true;
      }
      if (elapsedMs < closeAt) {
        drawRail(cx, cy, 1);
        return true;
      }
      if (elapsedMs < closeAt + CLOSE_ANIM_MS) {
        // the latch-hook reseals the rung: stitch fades back in as the rail
        // fades out, with the hook glyph visible for the transition
        const p = (elapsedMs - closeAt) / CLOSE_ANIM_MS;
        const eased = easeOutCubic(p);
        drawRail(cx, cy, 1 - eased);
        drawNormalStitch(cx, cy, age, eased);
        drawHookGlyph(cx, cy, 1 - p);
        return true;
      }
      // fully repaired — render as a normal stitch and let this row age out
      // of ladder tracking on the next active-ladder check naturally
      return false;
    };

    const maybeAdvanceLadder = (elapsedMs: number, currentCourse: number) => {
      if (ladder.active) {
        const lastCloseAt = ladder.repairStart + (ladder.length - 1) * CLOSE_STEP_MS + CLOSE_ANIM_MS;
        if (elapsedMs >= lastCloseAt) {
          ladder.active = false;
          ladder.nextTriggerAt =
            elapsedMs + LADDER_MIN_INTERVAL_MS + Math.random() * (LADDER_MAX_INTERVAL_MS - LADDER_MIN_INTERVAL_MS);
        }
        return;
      }
      if (elapsedMs < ladder.nextTriggerAt) return;
      const length = LADDER_MIN_LEN + Math.floor(Math.random() * (LADDER_MAX_LEN - LADDER_MIN_LEN + 1));
      if (currentCourse - length < 0) return; // not enough built history yet, wait
      ladder.active = true;
      ladder.column = Math.floor(Math.random() * cols);
      ladder.length = length;
      ladder.dropRow = currentCourse - length;
      ladder.openStart = elapsedMs;
      ladder.repairStart = elapsedMs + length * OPEN_STEP_MS;
    };

    const drawField = (elapsedMs: number, staticLadder: { column: number; dropRow: number; length: number } | null) => {
      if (!sized) return;
      ctx.clearRect(0, 0, width, height);

      const continuousCourse = elapsedMs / COURSE_MS;
      const currentCourse = Math.floor(continuousCourse);
      const buildEdgeY = height - pitch / 2;

      const minRow = Math.floor(continuousCourse - height / pitch) - 1;

      for (let rowIndex = Math.max(0, minRow); rowIndex <= currentCourse; rowIndex++) {
        const cy = buildEdgeY - (continuousCourse - rowIndex) * pitch;
        if (cy < -pitch || cy > height + pitch) continue;
        const age = currentCourse - rowIndex;

        for (let col = 0; col < cols; col++) {
          const cx = col * pitch + pitch / 2;

          if (staticLadder) {
            const rowOffset = rowIndex - staticLadder.dropRow;
            if (col === staticLadder.column && rowOffset >= 0 && rowOffset < staticLadder.length) {
              drawRail(cx, cy, 1);
              continue;
            }
            drawNormalStitch(cx, cy, age);
            continue;
          }

          if (col === ladder.column && drawLadderCell(rowIndex, cx, cy, elapsedMs, age)) continue;
          drawNormalStitch(cx, cy, age);
        }
      }
    };

    let raf = 0;
    let startTime = 0;

    const loop = (now: number) => {
      if (!startTime) startTime = now;
      const elapsedMs = now - startTime;
      const currentCourse = Math.floor(elapsedMs / COURSE_MS);
      maybeAdvanceLadder(elapsedMs, currentCourse);
      drawField(elapsedMs, null);
      if (visible && !document.hidden) raf = requestAnimationFrame(loop);
    };

    const drawReducedStatic = () => {
      const elapsedMs = STATIC_CURRENT_COURSE * COURSE_MS + COURSE_MS * 0.5;
      drawField(elapsedMs, { column: Math.floor(cols / 2), dropRow: STATIC_DROP_ROW, length: STATIC_LADDER_LEN });
    };

    const start = () => {
      if (reduced) {
        drawReducedStatic();
        return;
      }
      startTime = 0;
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(loop);
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (reduced) drawReducedStatic();
      }, 120);
    };

    const ro = new ResizeObserver(onResize);
    ro.observe(canvas);

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

    const onVis = () => {
      if (!document.hidden && visible && !reduced) start();
    };
    document.addEventListener("visibilitychange", onVis);

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced && sized) drawReducedStatic();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    readTokens();
    resize();
    start();

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

  return <canvas ref={canvasRef} aria-hidden="true" className={`block h-full w-full ${className}`} />;
}

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

Use when a card background or ambient feedback moment that needs to read as 'structurally sound, with occasional self-corrected faults' rather than a static success/health badge — the continuous knit build plus a recurring, always-repaired ladder fault communicates resilience over time instead of a single point-in-time status icon. Pick text-stitch-unpick instead for a pointer-driven, letter-by-letter, irreversible-per-letter reveal (a seam ripper on hover) — that grammar is deliberately reserved for pointer interaction and must not collide with this component's fully ambient, no-interaction ladder fault. Pick optimistic-stitch instead for a single row's pending/committed/rolled-back write lifecycle — that component never loops or repeats, this one is a continuous, unbounded, self-healing field.

Install

npx shadcn add https://design.helpmarq.com/r/knit-ladder-run.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 <KnitLadderRun className?> as a self-contained, aria-hidden 2D canvas (w-full h-full) that renders a knit-stitch field building continuously from the bottom edge upward. GRID: stitch pitch targets 10px; cols = clamp(round(width / 10), 14, 28) so the pattern reads at card scale on both small and large containers, pitch = width / cols (exact fit); every stitch is a small interlocked-loop glyph (two overlapping arcs, one opening down, one opening up, drawn as two canvas arc() strokes offset vertically by roughly a third of the stitch radius) in --foreground/--ns-muted, never text or an icon font. BUILD CLOCK: one continuous elapsed-ms counter drives continuousCourse = elapsedMs / 900 (COURSE_MS) and currentCourse = Math.floor(continuousCourse); a row's screen y = (height - pitch/2) - (continuousCourse - rowIndex) * pitch, so the newest course sits fixed near the bottom edge and every existing course drifts smoothly upward, eventually exiting past the top — an unbounded feed, never a fixed-length pass, matching a knitting machine's continuous take-down. Only rows with rowIndex <= currentCourse are ever drawn (future rows simply don't exist yet). AGE FADE (two-pass alpha overlay, same convention as other ascii/dot-grid siblings in this registry — never color-string arithmetic): every stitch is drawn once in --ns-muted at a fixed moderate base alpha (~0.55), then again in --foreground at alpha = clamp(1 - age/14, 0, 1) * ~0.75 where age = currentCourse - rowIndex — so stitches at the build edge read brightest (still only moderate opacity, per spec, never full-strength) and recede to the flat muted baseline by about 14 rows back, value only, no hue change. LADDER STATE MACHINE: one plain mutable object (never React state), gated to at most one active ladder at a time. Idle: nextTriggerAt is scheduled `elapsedMs + random(7000, 10000)`ms after the previous ladder's very last close animation finishes (or from mount for the first one). On trigger (only once currentCourse - length >= 0, i.e. enough history already exists — this naturally keeps t0 ladder-free per the spec's resting-loop description): pick length = randomInt(4,7), column = randomInt(cols), dropRow = currentCourse - length (the whole span already exists, so propagation never has to wait on new courses), openStart = elapsedMs, repairStart = openStart + length * 350ms (OPEN_STEP_MS). OPEN phase: for rowOffset k in [0, length), row (dropRow+k) collapses from a normal stitch to a bare gap starting at openAt = openStart + k*350ms, animated over a 120ms window (OPEN_ANIM_MS) — crossfade the stitch glyph's alpha down to 0 while a single --foreground vertical hairline (the run's rail, drawn as one moveTo/lineTo spanning the cell height) fades in to alpha 1; between openAt+120ms and its close time the cell is a pure rail, no glyph, so the run reads as absence, not a colored alert. REPAIR phase closes rungs bottom-up (the newest/last-opened rung closes FIRST, the original drop row reseals LAST) one rung every 300ms (CLOSE_STEP_MS): closeAt(k) = repairStart + (length-1-k)*300ms; over a 150ms window (CLOSE_ANIM_MS) starting at closeAt, crossfade the rail back to 0 while the stitch glyph fades back in, and draw a small latch-hook glyph (a short vertical stroke plus a partial arc, --foreground, same weight as normal stitches, never --ns-accent) at full-then-fading alpha marking the rung actively resealing — successive rungs closing bottom-up at a steady 300ms cadence reads as the hook glyph working its way upward. The ladder is marked inactive once the topmost (drop) row's close animation finishes; repair always completes well before a column could scroll off the top edge, so no explicit deadline logic is needed beyond the fixed cadence. TOKENS: only --foreground and --ns-muted are read, once via getComputedStyle(document.documentElement) and re-read on a MutationObserver watching documentElement's class (no paint before the first read); --ns-accent never appears anywhere, including on the hook glyph or the rail. CANVAS HOST: DPR-aware backing store capped at 2, ResizeObserver on the canvas element recomputing cols/pitch on a 120ms debounce, IntersectionObserver (threshold 0) and visibilitychange both pausing/resuming the rAF loop, full cleanup (cancelAnimationFrame, disconnect all observers) on unmount. REDUCED MOTION: prefers-reduced-motion renders one deterministic static frame (ROW_SETTLED) — NOT derived from the random ladder scheduler — with currentCourse fixed at 16 and a synthetic ladder fixed at column = floor(cols/2), dropRow = 11, length = 3, rendered as three bare open rails among otherwise-complete, normally-aged courses (no repair glyph, no rail/stitch crossfade animation, no rAF loop): a small ladder gap frozen mid-run, 2-3 rows open, not yet repaired, sitting among finished knit structure, redrawn only on a token change or a debounced resize. Purely decorative: canvas is aria-hidden, no pointer handling of any kind — a hover-driven 'picking' gesture would collide with text-stitch-unpick's established grammar. Props: className only.

Props

PropTypeDefaultDescription
className?string——