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.

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.

Source
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}`}
    />
  );
}
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?stringextra classes merged onto the rendered root element