ns-ui
Bobbin Lace Pricking
A decorative divider/empty-state motif: a narrow working band of pins twists thread pairs into locked crosses down a pricked grid, while a pin two rows behind the band is pulled free the moment its crossing has secured. The load-bearing, uncontested mechanic no other lattice sibling has.
Use when a decorative divider or empty-state ornament that wants a quiet, structured, continuously-working ambient motif rather than a functional control — pick this over divider-mosaic-split when a slow, physical building process (not a static pattern) is the point, or over empty-state-braille-orbit when a fixed, legible grid mechanic reads better than a comet-orbit trajectory. Distinct from mesh-lash, seep-lattice, and pin-register: none of those places-then-physically-removes an element as work progresses — this one's pin is a temporary fixture, present only while its crossing is still forming.
Install
npx shadcn add https://design.helpmarq.com /r/bobbin-lace-pricking.jsonSource
registry/core/bobbin-lace-pricking/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// BobbinLacePricking — a decorative divider/empty-state motif: a narrow
// WORKING BAND of pins works its way down a pricked grid, twisting a pair of
// threads into a locked X at each active pin, while pins two rows behind the
// band are pulled free once their crossing has secured into the growing lace
// ground. Source: bobbin lace (pillow/pricking) construction.
//
// The load-bearing, uncontested part of this mechanic (per spec) is that a
// pin is a TEMPORARY fixture — placed, used, then physically removed as work
// progresses — which is what separates it from every other lattice/mesh
// sibling in the registry (mesh-lash, seep-lattice, pin-register): none of
// those remove a placed element as the pattern advances.
//
// TIMELINE (one continuous clock, no per-pin React state):
// continuousRow = elapsedMs / ROW_INTERVAL (smooth fractional row depth)
// currentRow = floor(continuousRow) (the row being actively worked)
// rowElapsedMs = fractional part * ROW_INTERVAL
//
// Every row is classified purely by `age = currentRow - rowIndex`:
// age < 0 not yet reached -> border-token pricking dot only
// age == 0 actively crossing -> pin dot + a per-column-batch twist
// animation (3 batches, 700ms apart,
// 850ms twist each, mirroring "3 pins/
// row roughly staggered 700ms" scaled to
// any column count via `col % 3`)
// age == 1 just secured -> pin dot + locked X, full strength,
// static (already crossed, not yet pulled)
// age == 2 pull row -> exactly one column (cycling every
// interval) animates its pin sliding out
// + fading over 180ms while its locked X
// stays put; every other column in the
// row still shows a plain locked X + dot
// age >= 3 finished ground -> locked X only, no pin dot (already
// pulled), rendered at low-moderate
// opacity, never re-animated
//
// Exactly one row has age===2 at any instant, so exactly one pull event is
// ever in flight — the single, unmistakable "old pin behind the band" cue
// the spec names as the ONE thing to follow, on a ~2.6s cadence well above
// the ~1s legibility floor.
//
// The whole field scrolls continuously (band pinned at a fixed screen
// fraction, rows drift upward under it), so the loop never simply stops —
// new pricking keeps entering from the bottom, matching the spec's
// unbounded-scrolling-field option.
// ---------------------------------------------------------------------------
const ROW_INTERVAL_MS = 2600; // one full working-band row advance
const CROSS_MS = 850; // per-pin twist-and-lock duration
const BATCH_STAGGER_MS = 700; // stagger between the 3 within-row batches
const PULL_MS = 180; // pin slide-out + fade duration
const BAND_Y_FRAC = 1 / 3; // active row's screen position, fraction of height
const MIN_PITCH = 12;
const MAX_PITCH = 20;
const TARGET_PINS_ACROSS = 10; // used to derive pitch from the smaller dimension
const FINISHED_ALPHA = 0.38; // low-moderate opacity for set lace ground
const CROSS_HALF_LEN = 0.42; // fraction of pitch, half-length of each thread stroke
const STATIC_ROW = 3; // reduced-motion: currentRow at freeze
const STATIC_ROW_ELAPSED = 375; // ms into that row -> mid-cross on batch 0
function easeOutCubic(x: number): number {
const t = 1 - x;
return 1 - t * t * t;
}
export interface BobbinLacePrickingProps {
className?: string;
}
export function BobbinLacePricking({ className = "" }: BobbinLacePrickingProps) {
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 border = "currentColor";
let width = 0;
let height = 0;
let dpr = 1;
let sized = false;
let disposed = false;
let visible = true;
let pitch = 16;
let cols = 0;
let bandScreenY = 0;
const readTokens = () => {
const s = getComputedStyle(document.documentElement);
fg = s.getPropertyValue("--foreground").trim() || "currentColor";
border = s.getPropertyValue("--border").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);
const smaller = Math.min(width, height);
pitch = Math.min(MAX_PITCH, Math.max(MIN_PITCH, smaller / TARGET_PINS_ACROSS));
cols = Math.max(2, Math.floor(width / pitch));
bandScreenY = height * BAND_Y_FRAC;
sized = true;
};
// Two strokes rotating from parallel (theta=0) to a locked X (theta=45deg)
// around (cx, cy), eased. `full` (age>=1) draws the settled X directly.
const drawTwist = (cx: number, cy: number, progress: number, alpha: number) => {
const eased = easeOutCubic(Math.min(1, Math.max(0, progress)));
const theta = (eased * 45 * Math.PI) / 180;
const len = pitch * CROSS_HALF_LEN;
ctx.globalAlpha = alpha;
ctx.strokeStyle = fg;
ctx.lineWidth = 1;
ctx.lineCap = "round";
// thread A: rotates clockwise from vertical
ctx.beginPath();
ctx.moveTo(cx - Math.sin(theta) * len, cy - Math.cos(theta) * len);
ctx.lineTo(cx + Math.sin(theta) * len, cy + Math.cos(theta) * len);
ctx.stroke();
// thread B: rotates counter-clockwise from vertical
ctx.beginPath();
ctx.moveTo(cx + Math.sin(theta) * len, cy - Math.cos(theta) * len);
ctx.lineTo(cx - Math.sin(theta) * len, cy + Math.cos(theta) * len);
ctx.stroke();
ctx.globalAlpha = 1;
};
const drawPinDot = (cx: number, cy: number, alpha: number, radius: number) => {
ctx.globalAlpha = alpha;
ctx.fillStyle = fg;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
};
const drawHole = (cx: number, cy: number) => {
ctx.globalAlpha = 1;
ctx.fillStyle = border;
ctx.beginPath();
ctx.arc(cx, cy, 1, 0, Math.PI * 2);
ctx.fill();
};
const drawField = (continuousRow: number) => {
if (!sized) return;
ctx.clearRect(0, 0, width, height);
const currentRow = Math.floor(continuousRow);
const rowElapsedMs = (continuousRow - currentRow) * ROW_INTERVAL_MS;
const pullCol = ((currentRow % cols) + cols) % cols;
const pullProgress = Math.min(1, Math.max(0, rowElapsedMs / PULL_MS));
const minRow = Math.floor(continuousRow + (0 - bandScreenY) / pitch) - 1;
const maxRow = Math.ceil(continuousRow + (height - bandScreenY) / pitch) + 1;
for (let rowIndex = Math.max(0, minRow); rowIndex <= maxRow; rowIndex++) {
const age = currentRow - rowIndex;
const cy = bandScreenY + (rowIndex - continuousRow) * pitch;
const xOffset = (rowIndex % 2) * (pitch / 2);
for (let col = 0; col < cols; col++) {
const cx = xOffset + col * pitch + pitch / 2;
if (cx > width + pitch) continue;
if (age < 0) {
drawHole(cx, cy);
continue;
}
if (age === 0) {
const batch = col % 3;
const batchStart = batch * BATCH_STAGGER_MS;
const localProgress = (rowElapsedMs - batchStart) / CROSS_MS;
if (localProgress <= 0) {
drawPinDot(cx, cy, 1, 1.5);
} else {
drawTwist(cx, cy, localProgress, 1);
drawPinDot(cx, cy, 1, 1.5);
}
continue;
}
if (age === 1) {
drawTwist(cx, cy, 1, 1);
drawPinDot(cx, cy, 1, 1.5);
continue;
}
if (age === 2) {
drawTwist(cx, cy, 1, 1);
if (col === pullCol) {
const slideY = -6 * easeOutCubic(pullProgress);
const dotAlpha = 1 - pullProgress;
if (dotAlpha > 0.01) drawPinDot(cx, cy + slideY, dotAlpha, 1.5);
} else {
drawPinDot(cx, cy, 1, 1.5);
}
continue;
}
// age >= 3: finished ground, pin long since pulled
drawTwist(cx, cy, 1, FINISHED_ALPHA);
}
}
};
let raf = 0;
let startTime = 0;
const loop = (now: number) => {
if (!startTime) startTime = now;
const continuousRow = (now - startTime) / ROW_INTERVAL_MS;
drawField(continuousRow);
if (visible && !document.hidden) raf = requestAnimationFrame(loop);
};
const drawReducedStatic = () => {
const continuousRow = STATIC_ROW + STATIC_ROW_ELAPSED / ROW_INTERVAL_MS;
drawField(continuousRow);
};
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 () => {
disposed = true;
cancelAnimationFrame(raf);
if (resizeTimer) clearTimeout(resizeTimer);
ro.disconnect();
io.disconnect();
mo.disconnect();
document.removeEventListener("visibilitychange", onVis);
void disposed;
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={`block h-full w-full ${className}`}
/>
);
}
Build spec
Build <BobbinLacePricking className?> as a self-contained, aria-hidden 2D canvas (w-full h-full) that renders a pricked pin grid scrolling continuously under a fixed-position working band. Drive everything off ONE continuous clock: continuousRow = elapsedMs / ROW_INTERVAL_MS (ROW_INTERVAL_MS = 2600, a smooth fractional row depth), currentRow = Math.floor(continuousRow) is the row actively being worked, rowElapsedMs is the fractional remainder in ms. Pin pitch is derived from the container's SMALLER dimension: pitch = clamp(smallerDim / 10, 12, 20)px, cols = floor(width / pitch), and the whole field is a rectangular grid with a half-pitch x-offset on odd rows (a cheap diamond-stagger so it reads as a pricked lace pattern, not a plain checkerboard). The active row sits at a FIXED screen y = height * (1/3); every row's screen y = bandY + (rowIndex - continuousRow) * pitch, so the field visibly and continuously scrolls upward under the band (new pricking rows enter from the bottom forever — an unbounded loop, never a fixed-length pass). CLASSIFY EVERY VISIBLE ROW PURELY BY age = currentRow - rowIndex, no per-row/per-pin React or persisted state beyond the clock: age<0 draws a --border-token 1px dot only (untouched pricking, not yet reached — never a stroke on the lace itself). age===0 is the actively-crossing row: split columns into 3 batches via `col % 3`, batch start = batch * 700ms (BATCH_STAGGER_MS), each pin's local twist progress = (rowElapsedMs - batchStart) / 850ms (CROSS_MS) eased with easeOutCubic, drawn as two --foreground strokes that start parallel/vertical (theta=0) and rotate to a locked X (theta=45deg from vertical) as progress goes 0->1, plus a small filled pin dot on top; before its batch starts the pin shows dot-only (no cross yet). age===1 draws the settled X (theta=45deg, full strength) plus its dot — already crossed, not yet pulled. age===2 is the single 'pull row' at any instant: draw every column's settled X at full strength, and for exactly one column (pullCol = ((currentRow % cols) + cols) % cols, i.e. cycling one column per row-interval) additionally animate that column's dot only — slideY = -6px * easeOutCubic(pullProgress), alpha = 1 - pullProgress, pullProgress = clamp(rowElapsedMs / 180ms, 0, 1) (PULL_MS) — a sharp upward slide-out+fade of the pin glyph ALONE; the thread crossing it secured is drawn normally underneath and never animates or disappears. Every other column in an age===2 row still shows a plain dot + settled X (unpulled) exactly like age===1. age>=3 is finished lace ground: draw the settled X only, at a fixed low-moderate alpha (FINISHED_ALPHA = 0.38) in --foreground, no pin dot (long since pulled), and never recompute or re-animate it once drawn — rows there are cosmetically identical every frame apart from their continuous scroll position. This age-based classification guarantees exactly one row has age===2 (one pull event in flight) and exactly one row has age===0 (one crossing row in flight) at any instant, which is the ONE thing a viewer should track: the working band's position plus the pull of the pin just behind it, both well above the ~1s legibility floor (850ms crosses, one pull event every 2.6s with a 180ms visible slide, never a blink). TOKENS: colors read once via getComputedStyle(document.documentElement).getPropertyValue for --foreground and --border only (no --ns-accent anywhere — this is ambient, never interaction chrome, and the climactic cross/pull moments must never touch it), re-read on a MutationObserver watching documentElement's class so both themes render correctly without remount; --border is used ONLY for the not-yet-reached pricking dots (its correct, faint, separator/placeholder use), never as a stroke on any lace thread. CANVAS HOST: DPR-aware backing store capped at 2, ResizeObserver on the canvas element (not window.resize) recomputing pitch/cols/bandY on a 120ms debounce, IntersectionObserver (threshold 0) and visibilitychange both pausing/resuming the rAF loop, full cleanup (cancelAnimationFrame, disconnect all three observers) on unmount. No paint happens before the first token read — readTokens() and resize() both run synchronously before the first start(), and the same pair reruns on the ResizeObserver and MutationObserver paths. REDUCED MOTION: prefers-reduced-motion freezes on a single deliberately-chosen non-t0 frame — continuousRow pinned at STATIC_ROW (3) + STATIC_ROW_ELAPSED (375ms) / ROW_INTERVAL_MS, which lands mid-twist on the first crossing batch (col%3===0, progress ~0.44) with the band positioned roughly a third down the field, finished ground above and untouched pricking below (the PIN_SET freeze frame) — drawn once with no rAF loop, and re-drawn (still frozen) only on a token change or a debounced resize. Purely decorative: the canvas is aria-hidden, carries no interactive affordance, and any real accessible content in a consuming empty-state/divider lives as ordinary DOM alongside it. Props: className only.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| className? | string | — | — |