Skip to main content

ns-ui / loud

Interlace Field Comb

A full-bleed background reproducing analog interlaced video's defining artifact: two temporally-offset fields drawn a beat apart, disagreeing on a moving diagonal edge and serrating it into a comb, then periodically re-weaving into one clean frame.

Usage

registry/loud/interlace-field-comb/demo.tsx
"use client";

import { InterlaceFieldComb } from "./component";

export default function InterlaceFieldCombDemo() {
  return (
    <main className="h-screen w-full overflow-hidden bg-background">
      <InterlaceFieldComb>
        {/* a token scrim, not a colour literal — the field spans a wide tonal
            range in both themes, so unbacked type would sit on it unevenly */}
        <div className="flex flex-col items-center gap-3 rounded-lg bg-background/70 px-7 py-5 backdrop-blur-md">
          <p className="font-mono text-[11px] uppercase tracking-[0.28em] text-ns-muted">
            ns-ui / interlace-field-comb
          </p>
          <p className="max-w-sm text-sm text-foreground sm:text-base">
            Two fields, drawn a beat apart, keep drifting out of register and
            snapping back into one clean weave.
          </p>
        </div>
        <a
          href="#docs"
          className="pointer-events-auto mt-1 inline-flex w-fit items-center justify-center rounded-sm bg-ns-accent px-5 py-2.5 text-sm font-medium text-white transition-colors duration-150 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
        >
          Read the docs
        </a>
      </InterlaceFieldComb>
    </main>
  );
}

Component

registry/loud/interlace-field-comb/component.tsx
"use client";

import { useEffect, useRef } from "react";
import type { ReactNode } from "react";

// ---------------------------------------------------------------------------
// InterlaceFieldComb — a full-bleed background reproducing analog interlaced
// video's defining artifact: combing. Interlaced NTSC/PAL video draws a frame
// as two temporally-offset fields — odd scanlines, then even scanlines, each
// a separate instant. A still frame "weaves" the two fields into one clean
// picture; anything that moved BETWEEN the two field captures makes the odd
// and even rows disagree on where an edge sits, and a diagonal edge serrates
// into a visible horizontal comb. That is the mechanic here: content is drawn
// twice, once per field, with a small relative horizontal offset between the
// two draws that grows and shrinks over time, and the composite interleaves
// real device-pixel rows from each field's render — not a filter simulating
// the look, an actual two-buffer interleave.
//
// This is deliberately distinct from two already-shipped CRT artifacts on
// this registry: flyback-tear is signal/sync LOSS (rolling hold, tear bursts,
// dropout, snow) and rolling-shutter-skew is a CMOS sensor's per-row READOUT
// skew. Combing is neither — it is two co-owned, temporally offset scanline
// sets disagreeing on a moving edge, which only reads correctly when the two
// fields are genuinely composited from separate renders, not derived from one
// buffer with a wobble applied.
//
// DECOUPLING (round 9 note): a real interlaced field rate sits near 50-60Hz,
// which would alias against the page's own 60Hz paint and read as a glitch,
// not as interlacing. So the divergence between fields is driven by a slow,
// deliberately visualized 4.0s cycle instead of any real field rate — see
// PERIOD below — and the field-pair render itself is throttled well under
// 60Hz (DRAW_HZ) so the two-buffer interleave cost stays cheap.
//
// CONTENT: three long, soft-edged diagonal bands (getting their softness from
// ctx.filter blur on a plain rotated fillRect, the same technique already
// used by ring-graze/agar-starve in this registry) drifting slowly and
// wrapping. Diagonal, not horizontal, on purpose — a horizontal edge sitting
// exactly on a scanline boundary would show no comb at all; the bands need a
// real diagonal so a horizontal misalignment between fields visibly staggers
// their edges into steps.
//
// SUBSTRATE: 2D canvas. Two off-DOM canvases hold one field's render each;
// the composite interleaves their pixel rows in groups of PITCH device rows
// (derived from the container's smaller dimension) via ImageData.set() row
// copies, then a single putImageData onto the visible canvas. No WebGL.
// ---------------------------------------------------------------------------

interface Band {
  angleDeg: number; // direction the band's LENGTH runs
  thicknessFrac: number; // of min(W, H)
  speedMul: number; // multiplier on DRIFT_SPEED, travel perpendicular to length
  phaseFrac: number; // 0..1, initial position along the wrap range
  tone: "fg" | "muted";
  alpha: number;
}

const BANDS: Band[] = [
  { angleDeg: 24, thicknessFrac: 0.16, speedMul: 1, phaseFrac: 0.1, tone: "fg", alpha: 0.5 },
  { angleDeg: -16, thicknessFrac: 0.11, speedMul: 0.7, phaseFrac: 0.55, tone: "muted", alpha: 0.42 },
  { angleDeg: 38, thicknessFrac: 0.08, speedMul: 1.3, phaseFrac: 0.82, tone: "fg", alpha: 0.3 },
];

const DRIFT_SPEED = 6; // css px/s, perpendicular travel of each band
const PERIOD = 4.0; // s — the re-weave cycle: divergence returns to ~0 every PERIOD
const DRAW_HZ = 30; // the field-pair render + interleave is throttled to this, not 60Hz
const DRAW_INTERVAL_MS = 1000 / DRAW_HZ;

// The one instant the two fields fully agree — divergence(t) hits exactly 0
// at t = PERIOD / 2 with the phase chosen below (max divergence sits at t=0
// instead, matching the resting loop's t0 read). This is also the most
// structured frame: the underlying bands are visible with zero comb, which
// reduced-motion freezes on rather than the mid-divergence t=0 state.
const STATIC_TIME = PERIOD / 2;

function divergencePx(t: number, maxOffset: number): number {
  return (maxOffset * (1 + Math.cos((2 * Math.PI * t) / PERIOD))) / 2;
}

function drawField(
  ctx: CanvasRenderingContext2D,
  w: number,
  h: number,
  t: number,
  xOffset: number,
  fg: string,
  muted: string
) {
  ctx.clearRect(0, 0, w, h);
  const diag = Math.hypot(w, h);
  const minDim = Math.min(w, h);
  for (const b of BANDS) {
    const wrapRange = diag * 1.3;
    const travel = ((t * DRIFT_SPEED * b.speedMul + b.phaseFrac * wrapRange) % wrapRange + wrapRange) % wrapRange;
    const pos = travel - diag * 0.15;
    const angle = (b.angleDeg * Math.PI) / 180;
    // perpendicular unit vector — this is the axis the band travels along
    const perpX = Math.cos(angle + Math.PI / 2);
    const perpY = Math.sin(angle + Math.PI / 2);
    const cx = w / 2 + perpX * (pos - diag / 2) + xOffset;
    const cy = h / 2 + perpY * (pos - diag / 2);
    const thickness = b.thicknessFrac * minDim;
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(angle);
    ctx.filter = `blur(${(thickness * 0.55).toFixed(1)}px)`;
    ctx.fillStyle = b.tone === "fg" ? fg : muted;
    ctx.globalAlpha = b.alpha;
    ctx.fillRect(-diag * 0.7, -thickness / 2, diag * 1.4, thickness);
    ctx.restore();
  }
  ctx.filter = "none";
  ctx.globalAlpha = 1;
}

export interface InterlaceFieldCombProps {
  /** headline / CTA rendered over the field */
  children?: ReactNode;
  className?: string;
  style?: React.CSSProperties;
}

export function InterlaceFieldComb({ children, className = "", style }: InterlaceFieldCombProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const oddCanvas = document.createElement("canvas");
    const evenCanvas = document.createElement("canvas");
    const oddCtx = oddCanvas.getContext("2d", { willReadFrequently: true });
    const evenCtx = evenCanvas.getContext("2d", { willReadFrequently: true });
    if (!oddCtx || !evenCtx) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // token fields start empty; nothing paints until readTokens() has run —
    // guarded in draw() below, closing every path (rAF, resize, the reduced
    // branch) that could otherwise paint a literal.
    let fgColor = "";
    let mutedColor = "";
    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      fgColor = cs.getPropertyValue("--foreground").trim();
      mutedColor = cs.getPropertyValue("--ns-muted").trim();
    };

    let w = 0;
    let h = 0;
    let dpr = 1;
    let pitchDev = 1; // device rows per field line
    let maxOffsetCss = 1;
    let sized = false;
    let visible = true;
    let raf = 0;
    let t = reduced ? STATIC_TIME : 0;
    let last = 0;
    let lastDrawMs = -Infinity;
    let composite: ImageData | null = null;

    const resize = () => {
      const rect = root.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) {
        sized = false;
        return;
      }
      w = rect.width;
      h = rect.height;
      dpr = Math.min(window.devicePixelRatio || 1, 1.5);
      const pw = Math.max(1, Math.round(w * dpr));
      const ph = Math.max(1, Math.round(h * dpr));
      canvas.width = pw;
      canvas.height = ph;
      oddCanvas.width = pw;
      oddCanvas.height = ph;
      evenCanvas.width = pw;
      evenCanvas.height = ph;
      oddCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
      evenCtx.setTransform(dpr, 0, 0, dpr, 0, 0);

      const minDim = Math.min(w, h);
      const pitchCss = Math.max(1, Math.min(3, Math.round(minDim / 260)));
      pitchDev = Math.max(1, Math.round(pitchCss * dpr));
      maxOffsetCss = pitchCss * 2;
      composite = new ImageData(pw, ph);
      sized = true;
      lastDrawMs = -Infinity; // force an immediate redraw at the new size
    };

    const draw = () => {
      if (!sized || !fgColor || !composite) return;
      const pw = canvas.width;
      const ph = canvas.height;
      const div = divergencePx(t, maxOffsetCss);
      drawField(oddCtx, w, h, t, div / 2, fgColor, mutedColor);
      drawField(evenCtx, w, h, t, -div / 2, fgColor, mutedColor);
      const oddData = oddCtx.getImageData(0, 0, pw, ph).data;
      const evenData = evenCtx.getImageData(0, 0, pw, ph).data;
      const out = composite.data;
      const rowBytes = pw * 4;
      for (let y = 0; y < ph; y++) {
        const scan = Math.floor(y / pitchDev);
        const src = scan % 2 === 0 ? oddData : evenData;
        const start = y * rowBytes;
        out.set(src.subarray(start, start + rowBytes), start);
      }
      ctx.putImageData(composite, 0, 0);
    };

    const loop = (now: number) => {
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
      last = now;
      t += dt;
      if (now - lastDrawMs >= DRAW_INTERVAL_MS) {
        lastDrawMs = now;
        draw();
      }
      if (visible && !document.hidden) raf = requestAnimationFrame(loop);
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const ro = new ResizeObserver(() => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (reduced) draw();
      }, 120);
    });
    ro.observe(root);

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

    const onVis = () => {
      if (!document.hidden && visible && !reduced && !raf) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced || !raf) draw();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    readTokens();
    resize();

    if (reduced) {
      draw();
    } else {
      raf = requestAnimationFrame(loop);
    }

    return () => {
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  return (
    <div
      ref={rootRef}
      className={`relative isolate min-h-screen w-full overflow-hidden bg-background ${className}`}
      style={style}
    >
      <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 block h-full w-full" />
      {children ? (
        <div className="relative z-10 flex min-h-screen w-full flex-col items-center justify-center gap-4 px-6 text-center">
          {children}
        </div>
      ) : null}
    </div>
  );
}

InterlaceFieldComb.displayName = "InterlaceFieldComb";

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

Use when Pick interlace-field-comb for a full-bleed hero/background that should read as a transmitted signal losing sync WITHOUT signal loss itself — pure field-pair disagreement on motion, cycling clean and serrated. Pick flyback-tear instead when the failure should be a broadcast losing hold, tearing and dropping out; rolling-shutter-skew instead when the artifact should read as a camera sensor's own per-row readout skew rather than two co-owned fields disagreeing.

Install

npx shadcn add https://design.helpmarq.com/r/interlace-field-comb.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 <InterlaceFieldComb children? className? style?> as a full-bleed 2D-canvas background reproducing analog interlaced video's combing artifact. THE MECHANIC: interlaced NTSC/PAL video draws a frame as two temporally-offset fields, odd scanlines then even scanlines, each a separate captured instant. A still frame weaves the two fields into one clean picture; anything that moved between the two field captures makes odd and even rows disagree on where an edge sits, so a diagonal edge serrates into a visible horizontal comb. This is reproduced literally, not simulated with a filter: content is rendered TWICE into two off-DOM canvases (one per field) with a small relative horizontal offset between the two renders, then the visible canvas is built by copying real device-pixel ROWS from each field's buffer in alternating groups of PITCH rows (pitch derived from the container's smaller dimension, clamp(round(minDim/260),1,3) css px, converted to device px) via ImageData row copies and a single putImageData — an actual two-buffer interleave, not a wobble on one buffer. THE OFFSET BETWEEN FIELDS is driven by divergence(t) = maxOffset * (1 + cos(2*PI*t/PERIOD)) / 2 with PERIOD = 4.0s and maxOffset = pitchCss * 2 px; the odd field gets +divergence/2 horizontal offset, the even field gets -divergence/2. This is a DELIBERATELY DECOUPLED, slow visualized cycle (round 9 lesson) rather than a literal 50/60Hz field rate, which would alias against the page's own paint rate and read as a glitch rather than as interlacing. At t=0 divergence is at its maximum (visible comb); it returns to exactly 0 at t = PERIOD/2 = 2.0s, the moment of full re-weave, and repeats every PERIOD after that. CONTENT is three long, soft-edged diagonal bands (angles 24deg, -16deg, 38deg; NOT horizontal — a horizontal edge sitting on a scanline boundary shows no comb at all, so the bands must run diagonally for a horizontal misalignment to visibly stagger their edges into steps), each a rotated ctx.filter-blurred fillRect drifting perpendicular to its own length at 6 css px/s * a per-band speed multiplier, wrapping modulo 1.3x the canvas diagonal so no seam is visible. Softness comes from ctx.filter = blur(thickness*0.55px) on a plain fillRect, the same technique already used by ring-graze/agar-starve in this registry — no gradients needed. THROTTLING: the full field-pair render + interleave (the expensive part: two off-DOM canvas draws, two getImageData calls, a row-copy interleave, one putImageData) is throttled to 30Hz (DRAW_INTERVAL_MS = 1000/30) inside the rAF loop, not run every frame — the simulation clock itself still advances every rAF tick so DRAW_HZ throttling never desyncs the divergence curve from wall time. PALETTE: --foreground and --ns-muted read via getComputedStyle(document.documentElement), re-read on a documentElement class MutationObserver; bands alternate between the two tones (alpha 0.3-0.5) with no accent anywhere — the mechanic has no pointer interaction to justify one. HOST: DPR capped at 1.5 (this component's per-frame cost is two full-canvas raster ops plus row copies, not a single shader pass, so it stays conservative like weld-pool rather than flyback-tear's uncapped 2). ResizeObserver (120ms debounce) reallocates all three canvases (visible + both off-DOM field buffers) and recomputes pitch/maxOffset for the new size; IntersectionObserver (threshold 0) and visibilitychange both pause the rAF loop, since a full-bleed per-frame raster op running offscreen is the most expensive idle thing the page can carry. prefers-reduced-motion freezes the clock at STATIC_TIME = PERIOD/2 = 2.0s — the exact re-weave instant, chosen because it is the most structured frame (the three bands fully visible with zero comb) rather than t=0's already-diverged state. No colour literals anywhere. No pointer interaction of any kind — the mechanic is a fixed timing artifact, not a hover effect, and the showpiece recipe's accent-highlight failure mode does not apply because nothing here is pointer-driven.

Props

PropTypeDefaultDescription
children?ReactNode—headline / CTA rendered over the field
className?string——
style?React.CSSProperties——