Skip to main content

ns-ui

Warp Knit Tricot Lapping

An ambient divider/background texture whose guide bar shogs a short lateral distance every course, lapping each needle's thread diagonally across its neighbours to build a continuously-scrolling zigzag chevron field. The diagonal wale structure of warp knitting, distinct from a weft shuttle's full-width crossing.

Usage

registry/core/warp-knit-tricot-lapping/demo.tsx
"use client";

import { WarpKnitTricotLapping } from "./component";

export default function WarpKnitTricotLappingDemo() {
  return (
    <main className="min-h-screen bg-background">
      <header className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
        <p className="font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / warp-knit-tricot-lapping
        </p>
        <p className="hidden font-mono text-[11px] text-ns-muted sm:block">
          guide bar shogs 2 needle-spaces, alternating, building a diagonal chevron every course
        </p>
      </header>
      <div className="h-24 w-full border-b border-border sm:h-28">
        <WarpKnitTricotLapping />
      </div>
    </main>
  );
}

Component

registry/core/warp-knit-tricot-lapping/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// WarpKnitTricotLapping — an ambient warp-knit (tricot) guide-bar lapping
// texture. Unlike weft knitting, every needle has its own yarn fed from a
// guide bar that holds ALL yarns in a row; between courses the whole bar
// SHOGS (shifts) a short lateral distance before swinging back to the
// needles, so each thread laps diagonally across neighbouring needles
// course after course rather than staying in one column. That diagonal
// shog-and-lap is what gives tricot its run-resistant diagonal wale
// structure (unlike weft knit, no single dropped loop can ladder the whole
// column) — the direct opposite motion of a weft-insertion shuttle pass
// (loader-loom-weave), which crosses the FULL width of a fixed warp.
//
// Every needle in a row shares the SAME guide-bar offset, so drawing one
// diagonal segment per needle per course — from its previous shogged
// position to its new one — produces a field of parallel diagonal lines
// that reverse direction in lockstep every course: the classic tricot
// herringbone/chevron. A basic "2 and 2" lapping chain: the bar shogs 2
// needle-spaces right, then 2 needle-spaces left, alternating every course,
// so the offset itself is a triangle wave (0, +2, 0, +2, ...) rather than
// an unbounded drift.
//
// One course completes every 500ms: first a 200ms eased lateral slide of
// the whole guide row (a bright tick per needle, "where the work is
// happening"), then the lap segments for that course commit in a single
// instant redraw — never a gradually-growing line. A separate, decoupled
// clock scrolls the accumulated field upward at one course-height per
// 500ms, so completed courses feed off the top edge while the guide bar
// itself stays anchored at a fixed frontier near the bottom. The field is
// seeded with several courses synchronously at mount so it reads as
// mid-build immediately rather than growing from nothing.
// ---------------------------------------------------------------------------

const COURSE_PERIOD_MS = 500; // one course (shog + lap) per 500ms
const SHOG_SLIDE_MS = 200; // eased lateral slide before the lap segment lands
const SHOG_SPACES = 2; // "2 and 2" tricot chain: 2 needle-spaces per shog
const PITCH_DIVISOR = 27; // needle pitch derives from the smaller dimension
const PITCH_MIN = 8;
const PITCH_MAX = 14;
const COURSE_HEIGHT_RATIO = 9 / 11; // ~9px course height at ~11px pitch
const SETTLED_ALPHA = 0.62;
const GUIDE_ALPHA = 0.95;
const LINE_WIDTH = 1.15;

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

interface CourseRecord {
  k: number; // course index, worldY = k * courseHeight
  offsetPx: number; // committed lateral offset of the guide bar for this course
}

export interface WarpKnitTricotLappingProps {
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function WarpKnitTricotLapping({
  className = "",
}: WarpKnitTricotLappingProps) {
  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 width = 0;
    let height = 0;
    let sized = false;
    let disposed = false;
    let visible = true;

    let pitch = 11;
    let courseHeight = 9;
    let cols = 0;
    let frontierY = 0; // fixed y of the active guide bar / newest course

    let history: CourseRecord[] = [];
    let k = 0; // next course index to commit
    let offsetPrev = 0; // committed offset (px) at the last committed course
    let offsetTarget = 0; // offset (px) this course is shogging toward
    let direction = 1; // +1 = right, -1 = left, flips every course
    let courseStart = 0; // ms timestamp the current course cycle began
    let committed = false; // has this course's segment already been drawn?
    let baseScroll = 0; // ms-based accumulated scroll at last course boundary

    const readTokens = () => {
      fg = getComputedStyle(canvas).color;
    };

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

      const smaller = Math.min(width, height);
      pitch = Math.min(PITCH_MAX, Math.max(PITCH_MIN, smaller / PITCH_DIVISOR));
      courseHeight = pitch * COURSE_HEIGHT_RATIO;
      cols = Math.max(6, Math.round(width / pitch) + 2);
      frontierY = height - courseHeight;
      sized = true;
    };

    // world-y of a committed course (k=0 is the oldest ever seen) mapped to
    // screen space given how far the field has scrolled since it landed.
    const screenY = (courseK: number, scrollNow: number): number =>
      frontierY - (scrollNow - courseK * courseHeight);

    const scrollAt = (nowMs: number): number =>
      baseScroll + ((nowMs - courseStart) / COURSE_PERIOD_MS) * courseHeight;

    const commitCourse = () => {
      history.push({ k, offsetPx: offsetTarget });
      k += 1;
      committed = true;
      // cull courses once they've scrolled fully off the top
      const scrollNow = scrollAt(courseStart + COURSE_PERIOD_MS);
      while (history.length > 1) {
        const first = history[0];
        if (!first) break;
        if (screenY(first.k, scrollNow) < -courseHeight) history.shift();
        else break;
      }
    };

    const advanceCourse = () => {
      baseScroll = scrollAt(courseStart + COURSE_PERIOD_MS);
      courseStart += COURSE_PERIOD_MS;
      offsetPrev = offsetTarget;
      direction = -direction;
      offsetTarget = offsetPrev + direction * SHOG_SPACES * pitch;
      committed = false;
    };

    const draw = (nowMs: number) => {
      if (!sized) return;
      ctx.clearRect(0, 0, width, height);

      const scrollNow = scrollAt(nowMs);
      const tLocal = nowMs - courseStart;
      const slideT = Math.min(1, Math.max(0, tLocal / SHOG_SLIDE_MS));
      const guideOffset = committed
        ? offsetTarget
        : offsetPrev + (offsetTarget - offsetPrev) * easeOutCubic(slideT);

      ctx.strokeStyle = fg;
      ctx.lineWidth = LINE_WIDTH;
      ctx.lineCap = "round";

      // settled lap texture — every committed course-to-course pair
      ctx.globalAlpha = SETTLED_ALPHA;
      ctx.beginPath();
      for (let i = 1; i < history.length; i++) {
        const a = history[i - 1];
        const b = history[i];
        if (!a || !b) continue;
        const ay = screenY(a.k, scrollNow);
        const by = screenY(b.k, scrollNow);
        if (ay < -courseHeight && by < -courseHeight) continue;
        for (let n = 0; n < cols; n++) {
          const baseX = (n + 0.5) * pitch;
          ctx.moveTo(baseX + a.offsetPx, ay);
          ctx.lineTo(baseX + b.offsetPx, by);
        }
      }
      ctx.stroke();

      // guide bar indicator — one short tick per needle at the fixed
      // frontier, laterally offset by the guide's current (possibly still
      // sliding) position; this is the one thing to visually track each
      // course, moving right/left before the next commit lands the lap.
      const tick = courseHeight * 0.4;
      ctx.globalAlpha = GUIDE_ALPHA;
      ctx.beginPath();
      for (let n = 0; n < cols; n++) {
        const x = (n + 0.5) * pitch + guideOffset;
        ctx.moveTo(x, frontierY - tick);
        ctx.lineTo(x, frontierY + tick);
      }
      ctx.stroke();
      ctx.globalAlpha = 1;
    };

    // Synchronously build `count` committed courses ending in a settled
    // (just-landed, never mid-slide) state, then anchor the clock so the
    // newest course sits exactly at the frontier. Used both to seed the
    // live field to "mid-build, filling most of the panel" at t0 (rather
    // than growing from nothing over ~15s) and to produce the reduced-motion
    // LAP_SETTLED freeze frame — the same construction, just never ticked.
    const seedField = (nowMs: number, count: number) => {
      history = [];
      k = 0;
      offsetPrev = 0;
      offsetTarget = SHOG_SPACES * pitch;
      direction = 1;
      for (let c = 0; c < count; c++) {
        history.push({ k, offsetPx: offsetTarget });
        k += 1;
        offsetPrev = offsetTarget;
        direction = -direction;
        offsetTarget = offsetPrev + direction * SHOG_SPACES * pitch;
      }
      committed = true;
      courseStart = nowMs;
      baseScroll = (count - 1) * courseHeight;
    };

    const coursesToFill = () => Math.ceil(height / courseHeight) + 2;

    let raf = 0;
    const GAP_RESET_MS = COURSE_PERIOD_MS * 20; // long tab-hidden gap: resync instead of replay

    const loop = (now: number) => {
      raf = 0;
      if (!visible || !sized) return;

      if (now - courseStart > GAP_RESET_MS) {
        seedField(now, coursesToFill());
      } else {
        // commit the course being left BEFORE advancing off it, every time,
        // so `k`/`baseScroll` can never skew apart on a multi-course catch-up
        while (now - courseStart >= COURSE_PERIOD_MS) {
          if (!committed) commitCourse();
          advanceCourse();
        }
        if (now - courseStart >= SHOG_SLIDE_MS && !committed) commitCourse();
      }

      draw(now);
      raf = requestAnimationFrame(loop);
    };

    const start = () => {
      resize();
      if (!sized) return;
      const nowMs = performance.now();
      seedField(nowMs, coursesToFill());
      if (reduced) {
        draw(nowMs);
        return;
      }
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(loop);
    };

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced) {
        // re-anchor the frozen clock so scrollAt() still maps the same
        // settled field onto the frontier after an arbitrary real-time gap
        courseStart = performance.now();
        draw(courseStart);
      }
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    let resizeTimer = 0;
    const ro = new ResizeObserver(() => {
      window.clearTimeout(resizeTimer);
      resizeTimer = window.setTimeout(() => {
        if (disposed) return;
        cancelAnimationFrame(raf);
        raf = 0;
        start();
      }, 80);
    });
    ro.observe(canvas);

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !reduced && sized && !raf) {
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(canvas);

    readTokens();
    start();

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      window.clearTimeout(resizeTimer);
      mo.disconnect();
      ro.disconnect();
      io.disconnect();
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      className={`block h-full w-full text-foreground ${className}`}
    />
  );
}

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

Use when Pick warp-knit-tricot-lapping for a strip or panel that wants a continuously-building diagonal chevron texture with a single legible cadence (the guide bar's alternating shog) to track. Pick background-truchet-weave instead when the texture should read as one continuous interlaced arc curve organised into large soft domains with a pointer-reactive seam, not a discrete course-by-course build; pick divider-petscii-vu instead when the strip should read as a level/signal meter rather than a knit structure. loader-loom-weave is the opposite mechanic — a weft thread crossing the FULL width of a fixed warp — and should not be picked if the point is a short lateral shog.

Install

npx shadcn add https://design.helpmarq.com/r/warp-knit-tricot-lapping.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 <WarpKnitTricotLapping className?> as a card-scale <canvas w-full h-full>, ambient/autoplay only with no pointer interaction. GEOMETRY: needle pitch is derived from the container's smaller dimension, clamped to 8-14px around a ~11px target (pitch = clamp(min(w,h)/27, 8, 14)); needle columns = round(w/pitch)+2 so the field slightly overhangs both edges; course height = pitch * 9/11 (~9px at ~11px pitch), matching the spec's real-numbers ratio. STATE MACHINE: every needle in a row shares one guide-bar offset (in px), tracked as offsetPrev/offsetTarget. A course cycle is exactly 500ms: for the first 200ms the guide bar (a bright full-width horizontal line pinned at a fixed frontier y = height - courseHeight, the 'where the work is happening' indicator) eases from offsetPrev to offsetTarget via easeOutCubic; at the 200ms mark the course COMMITS in one instant redraw (never a gradually-growing line) — a CourseRecord {k, offsetPx: offsetTarget} is pushed to history and every needle's diagonal lap segment for that course becomes part of the settled texture. At the 500ms mark the course advances: offsetPrev = offsetTarget, direction flips sign, offsetTarget = offsetPrev + direction * 2 * pitch — a fixed 'shog 2 needle-spaces right, then 2 left' tricot chain, so offset is a triangle wave (0, +2*pitch, 0, +2*pitch, ...) rather than unbounded drift. RENDER: settled lap texture is one stroked path built by iterating consecutive history pairs and, for every needle column n, drawing a line from (baseX_n + courseA.offsetPx, screenY(courseA)) to (baseX_n + courseB.offsetPx, screenY(courseB)) — this produces the parallel-line chevron field, reversing direction every course. A field-scroll clock, decoupled from the course-commit cadence but running at the same real rate (one course-height of upward scroll per 500ms, via a continuous ms-based formula rather than a discrete jump), maps each committed course's fixed world position to a screen y; courses are culled from history once they scroll more than one course-height above the top edge. Stroke is `--foreground` via `currentColor` (canvas className carries text-foreground, colour read once with getComputedStyle(canvas).color and re-read on a MutationObserver watching documentElement's class attribute — no colour literal anywhere), uniform 1.15px line weight, no fill; the settled texture strokes at 0.62 alpha and the guide-bar indicator strokes at 0.95 alpha so the eye can find the active course at a glance in both themes. No `--border` on the lap/chevron itself and no `--ns-accent` anywhere. RESTING LOOP: at any instant the canvas shows a zigzag chevron mid-build with the guide bar mid-shog or freshly settled; over 2.5s roughly 5 courses commit with at least 2 visible chevron reversals, and by 5s the chevron pattern visible at t0 has scrolled off the top edge and been culled from history — alive and unbounded with zero input. LIFECYCLE: dpr clamped to 2, a debounced ResizeObserver re-measures pitch/courseHeight/frontier and restarts the course clock, an IntersectionObserver pauses the rAF loop when the canvas scrolls off-screen and resumes it (re-checking the elapsed-gap guard) when it re-enters, and a >20-course real-time gap (e.g. a long-backgrounded tab) triggers a silent resync of the field instead of replaying every missed course. prefers-reduced-motion synchronously replays 8 course commits (guaranteeing at least 2 direction reversals) at mount and freezes on LAP_SETTLED — the frame immediately after the 8th shog's lap segments have landed, guide bar at rest, never mid-slide — with no further ticking. Cleanup cancels the rAF, disconnects the MutationObserver/ResizeObserver/IntersectionObserver, and clears the resize debounce timer. The canvas is aria-hidden — it is decoration, no content a screen reader should announce. Props: className only.

Props

PropTypeDefaultDescription
className?string—extra classes merged onto the rendered root element