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.
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.jsonSource
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}`} />;
}
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
| Prop | Type | Default | Description |
|---|---|---|---|
| className? | string | — | — |