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.

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.

Source
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";
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?ReactNodeheadline / CTA rendered over the field
className?string
style?React.CSSProperties