Skip to main content

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.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/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

PropTypeDefaultDescription
className?string