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.jsonSource
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
| Prop | Type | Default | Description |
|---|---|---|---|
| children? | ReactNode | — | headline / CTA rendered over the field |
| className? | string | — | — |
| style? | React.CSSProperties | — | — |