ns-ui / loud
Rolling Shutter Skew
A full-bleed ambient background modeling CMOS rolling shutter: a vertical rule grid bends into a shifting parallelogram because each virtual sensor row captures the simulated pan at a slightly later instant than the row above it, the readout period accumulating into more displacement toward the bottom of the frame.
Use when Pick rolling-shutter-skew when the backdrop should read as a camera-side capture artifact — a straight grid bending into a parallelogram because rows were captured at different instants, continuous and never discontinuous. Pick grid-magnetic-lattice (registry/core/grid-magnetic-lattice) instead when the grid should bend toward the cursor as an interactive tool surface rather than run its own self-paced pan; pick background-capillary-wick when the backdrop should read as ink finding its way through a fixed veined lattice rather than any notion of camera motion.
Install
npx shadcn add https://design.helpmarq.com /r/rolling-shutter-skew.jsonSource
registry/loud/rolling-shutter-skew/component.tsx"use client";
import { useEffect, useRef } from "react";
import type { CSSProperties, ReactNode } from "react";
// ---------------------------------------------------------------------------
// RollingShutterSkew — a full-bleed ambient background modeling CMOS rolling
// shutter, the "jello effect" from a sensor that scans photosite rows
// top-to-bottom over a readout period instead of capturing every row at
// once. A vertical rule grid never sits straight: each of N virtual sensor
// rows samples a shared pan-velocity function at its own capture time
// (earlier rows are captured earlier than lower rows, by up to one full
// readout period), so a straight line bends into a piecewise parallelogram
// that keeps reshaping as the simulated pan oscillates. Skew is a pure,
// continuous function of elapsed time — no accumulated simulation state, no
// discrete steps, so "alive at rest" falls out of the formula itself rather
// than a stepped automaton.
//
// EXPLICITLY NOT flyback-tear (registry/loud/flyback-tear, cut on owner
// review — CRT family): flyback is a DISPLAY-side sync failure, a
// discontinuous tear/roll where the beam loses lock. This is CAPTURE-side
// and continuous by construction — every row is adjacent to its neighbor in
// both space and capture time, so the grid always bends smoothly, it never
// jumps or tears. If a build ever introduces a discontinuity here, that is
// the CRT-family bug this component exists specifically to avoid.
//
// EXPLICITLY NOT grid-magnetic-lattice (registry/core/grid-magnetic-lattice):
// that lattice bends toward the CURSOR via a gaussian pull field, an
// interactive tool grid. This grid bends from a self-running pan-velocity
// function that has nothing to do with cursor position; the optional
// pointer kick (below) perturbs the shared velocity term, it never creates
// a positional attractor.
//
// PHYSICS: pan velocity v(t) = 820*sin(2pi*t/5.8) + 140*sin(2pi*t/0.9) px/s
// — a slow base pan plus a faster handshake wobble, periods deliberately
// non-round so the combined phase doesn't visibly repeat on any short
// human-noticeable cycle. (The literal capture-side deceleration a real
// handheld rolling-shutter pan produces is only a few px of skew at 1/60s
// readout; velocity is scaled up roughly 6x from that literal figure so the
// bend reads clearly at showpiece scale — the Washburn-style capture-time
// formula itself, and the readout period, are the real physical numbers,
// only the pan amplitude is a showpiece exaggeration, same latitude
// weld-pool takes with specular structure.) Each of N_ROWS virtual sensor
// rows captures at captureTime(row) = t - (row/N)*T_RO, T_RO = 16.7ms (a
// 1/60s progressive CMOS readout), and its horizontal displacement is
// skew(row) = v(captureTime(row)) * (row/N) * T_RO — later rows accumulate
// more elapsed readout time and therefore more displacement, which is what
// bends a straight vertical rule into a parallelogram instead of just
// sliding it sideways.
//
// TOKENS: --foreground is the only ink color, drawn at fixed low alpha; no
// fill gradient, no --ns-accent anywhere — this is a resting ambient
// surface, and the optional pointer kick moves the SAME velocity term the
// ambient pan already uses, never a separate accent-tinted highlight.
// --background clears the canvas every frame. Both read via
// getComputedStyle(document.documentElement) only after document.fonts.ready
// resolves, before the first paint, and re-read on a MutationObserver
// watching documentElement's class; every path that could draw before that
// first read (ResizeObserver, IntersectionObserver, the reduced-motion
// branch) is gated behind a `ready` flag.
// ---------------------------------------------------------------------------
const TAU = Math.PI * 2;
const N_ROWS = 48; // virtual sensor rows sampled across the frame height
const T_RO = 0.0167; // s, CMOS progressive readout period (1/60s)
const PAN_PERIOD = 5.8; // s, slow base pan, deliberately non-round
const PAN_AMP = 820; // px/s
const WOBBLE_PERIOD = 0.9; // s, faster handshake component
const WOBBLE_AMP = 140; // px/s
const KICK_GAIN = 0.55; // px/s of extra velocity per px/s of pointer speed
const KICK_MAX = 420; // px/s, clamp on the pointer kick term
const KICK_TAU = 0.12; // s, exponential decay of the kick — back under 5% within ~400ms
const STATIC_TIME = 1.45; // reduced-motion freeze: quarter into the base pan, near peak velocity
const LINE_ALPHA = 0.35;
const LINE_WIDTH = 1; // css px at dpr 1
function panVelocity(t: number): number {
return PAN_AMP * Math.sin((TAU * t) / PAN_PERIOD) + WOBBLE_AMP * Math.sin((TAU * t) / WOBBLE_PERIOD);
}
function clamp(v: number, min: number, max: number): number {
return Math.min(max, Math.max(min, v));
}
export interface RollingShutterSkewProps {
/** grid spacing as a fraction of the container's smaller dimension. @default 1/24 */
gridSpacingRatio?: number;
/** freeze the field at its reduced-motion frame. @default false */
paused?: boolean;
children?: ReactNode;
className?: string;
style?: CSSProperties;
}
export function RollingShutterSkew({
gridSpacingRatio = 1 / 24,
paused = false,
children,
className = "",
style,
}: RollingShutterSkewProps) {
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 reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// token fields start empty and are assigned unconditionally from
// getComputedStyle before any draw path can run — no literal fallback.
let line = "";
let bg = "";
const readTokens = () => {
const cs = getComputedStyle(document.documentElement);
line = cs.getPropertyValue("--foreground").trim();
bg = cs.getPropertyValue("--background").trim();
};
let dpr = 1;
let width = 0;
let height = 0;
let sized = false;
let ready = false;
let disposed = false;
let visible = true;
let raf = 0;
let last = 0;
let simTime = 0;
let kickVel = 0;
let havePointer = false;
let lastPointerX = 0;
let lastPointerT = 0;
const onPointerMove = (e: PointerEvent) => {
if (reduced || paused) return;
const now = performance.now();
if (havePointer) {
const dt = Math.max(0.001, (now - lastPointerT) / 1000);
const vx = (e.clientX - lastPointerX) / dt;
kickVel = clamp(kickVel + vx * KICK_GAIN, -KICK_MAX, KICK_MAX);
}
lastPointerX = e.clientX;
lastPointerT = now;
havePointer = true;
};
const onPointerLeave = () => {
havePointer = false;
};
root.addEventListener("pointermove", onPointerMove);
root.addEventListener("pointerleave", onPointerLeave);
const draw = () => {
if (!sized || !ready) return;
ctx.fillStyle = bg;
ctx.fillRect(0, 0, width, height);
const t = reduced ? STATIC_TIME : simTime;
const kick = reduced ? 0 : kickVel;
const spacing = Math.max(18, Math.min(width, height) * gridSpacingRatio);
const count = Math.ceil(width / spacing) + 2;
const rowH = height / N_ROWS;
ctx.strokeStyle = line;
ctx.globalAlpha = LINE_ALPHA;
ctx.lineWidth = LINE_WIDTH;
ctx.lineJoin = "round";
ctx.beginPath();
for (let li = -1; li < count; li++) {
const baseX = li * spacing;
for (let row = 0; row <= N_ROWS; row++) {
const frac = row / N_ROWS;
const captureTime = t - frac * T_RO;
const v = panVelocity(captureTime) + kick;
const skew = v * frac * T_RO;
const x = baseX + skew;
const y = row * rowH;
if (row === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.globalAlpha = 1;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) {
sized = false;
return;
}
width = rect.width;
height = rect.height;
dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sized = true;
};
const loop = (now: number) => {
if (!visible) return;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
last = now;
simTime += dt;
kickVel *= Math.exp(-dt / KICK_TAU);
draw();
raf = requestAnimationFrame(loop);
};
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
resize();
if (!sized) return;
draw();
if (!reduced && !paused && visible && !raf) {
last = 0;
raf = requestAnimationFrame(loop);
}
}, 150);
};
const ro = new ResizeObserver(onResize);
ro.observe(canvas);
const io = new IntersectionObserver(
(entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible && ready && !reduced && !paused) {
last = 0;
raf = requestAnimationFrame(loop);
} else {
cancelAnimationFrame(raf);
raf = 0;
}
},
{ threshold: 0 }
);
io.observe(canvas);
const onVis = () => {
if (document.hidden) {
cancelAnimationFrame(raf);
raf = 0;
} else if (visible && ready && !reduced && !paused) {
last = 0;
raf = requestAnimationFrame(loop);
}
};
document.addEventListener("visibilitychange", onVis);
const mo = new MutationObserver(() => {
readTokens();
if (reduced || paused) draw();
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
document.fonts.ready.then(() => {
if (disposed) return;
readTokens();
resize();
if (!sized) {
ready = true;
return;
}
ready = true;
draw();
if (!reduced && !paused) {
raf = requestAnimationFrame(loop);
}
});
return () => {
disposed = true;
cancelAnimationFrame(raf);
if (resizeTimer) clearTimeout(resizeTimer);
ro.disconnect();
io.disconnect();
mo.disconnect();
document.removeEventListener("visibilitychange", onVis);
root.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerleave", onPointerLeave);
};
}, [gridSpacingRatio, paused]);
return (
<div
ref={rootRef}
className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}
style={style}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full"
/>
{children ? <div className="relative z-[1] h-full w-full">{children}</div> : null}
</div>
);
}
RollingShutterSkew.displayName = "RollingShutterSkew";
Build spec
Build <RollingShutterSkew gridSpacingRatio? paused? children? className? style?> as a full-bleed Canvas 2D background modeling REAL CMOS ROLLING SHUTTER — a sensor that reads photosite rows top-to-bottom over a readout period instead of capturing every row at once (the 'jello effect' seen on phone cameras panning past a fence). MECHANIC: draw a grid of vertical rules spaced at max(18, min(width,height) * gridSpacingRatio) (default gridSpacingRatio 1/24). Each rule is NOT a straight line: it is built from N_ROWS=48 points, one per virtual sensor row, where row `row` (0..48) is captured at captureTime(row) = t - (row/N_ROWS)*T_RO, T_RO = 0.0167s (a 1/60s progressive CMOS readout), and displaced horizontally by skew(row) = panVelocity(captureTime(row)) * (row/N_ROWS) * T_RO — later rows have accumulated more elapsed readout time and therefore more displacement, which is what bends a straight vertical line into a piecewise parallelogram instead of just sliding it sideways. panVelocity(t) = 820*sin(2*PI*t/5.8) + 140*sin(2*PI*t/0.9) px/s: a slow base pan (period 5.8s) plus a faster handshake wobble (period 0.9s), both periods deliberately non-round so the combined phase never visibly repeats on a short human-noticeable cycle. The pan amplitude is intentionally exaggerated roughly 6x above a literal handheld pan's contribution at 1/60s readout (which would only bend the grid a couple of px) so the effect reads clearly at showpiece scale — the readout period and the per-row capture-time formula are the real physical numbers, only the pan amplitude is a showpiece exaggeration, the same latitude weld-pool takes with its specular structure. EXPLICITLY NOT registry/loud/flyback-tear (cut on owner review, CRT family): flyback is a DISPLAY-side sync failure, a discontinuous tear/roll where the beam loses lock and the image jumps. This is CAPTURE-side and continuous by construction — adjacent rows are always adjacent in both space and capture time, so the grid bends smoothly and NEVER jumps or tears; if an implementation introduces any discontinuity, that is the exact CRT-family bug this component exists to avoid. EXPLICITLY NOT registry/core/grid-magnetic-lattice: that lattice bends toward the CURSOR via a gaussian pull field, an interactive tool grid with a positional attractor. This grid's bend comes entirely from the self-running panVelocity(t) function; skew is a pure, continuous function of elapsed time with no accumulated simulation state and no discrete steps, so ALIVE AT REST falls out of the formula itself — at any sampled instant the grid's shape is provably different because panVelocity's two non-resonant sine periods are never simultaneously at the same phase twice within any short window. POINTER (optional, do not gate the ambient loop on it): pointermove over the component measures instantaneous pointer velocity and adds a clamped kick term (gain 0.55, clamp +-420 px/s) to a shared `kickVel` value that is simply ADDED to panVelocity's output before computing skew — it perturbs the same shared velocity term the ambient pan already uses, it is never a separate positional attractor or a second highlight, and it exponentially decays (tau=0.12s, effectively fully decayed within ~400ms) back to zero whether or not the pointer keeps moving. TOKENS: --foreground is the only ink color, drawn at fixed alpha 0.35, lineWidth 1 (css px at dpr 1) — no gradient, no fill. --ns-accent never appears anywhere in the canvas; this is a resting ambient surface, and the pointer kick modifies the SAME shared physics term the idle animation already runs on rather than drawing a second accent-tinted highlight. --background clears the canvas every frame. Both tokens are read via getComputedStyle(document.documentElement) only after document.fonts.ready resolves, before the first paint, and re-read on a MutationObserver watching documentElement's class; every path that could draw before that first read (ResizeObserver, IntersectionObserver, the reduced-motion branch) is gated behind a `ready` flag so nothing paints with an empty token string. REDUCED MOTION: prefers-reduced-motion (and the paused prop) freeze t at STATIC_TIME=1.45s — a quarter into the base 5.8s pan period, near its peak velocity — and draw exactly that frame once, chosen because it shows clear asymmetric shear rather than the near-straight read a t where the pan sinusoid crosses zero would give. SCALE: grid spacing derives from min(width, height) so it reads proportionally at both full-bleed hero scale and a small preview-card crop; N_ROWS stays fixed at 48 regardless of container size since it represents virtual sensor rows, not a visual density knob. HOST: DPR-capped backing store (max 1.5, matching weld-pool's full-bleed cap), IntersectionObserver (threshold 0) and visibilitychange both stop the single rAF loop, ResizeObserver re-sizes the backing store on layout change (skew needs no rebuild on resize, it is a pure function of time and current width/height). The canvas is aria-hidden and pointer-events-none; the pointermove/pointerleave listeners live on the wrapping root element instead so the ambient kick works without the canvas itself needing to be hit-testable; children render as real DOM above the canvas and are never intercepted by it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| gridSpacingRatio? | number | 1 / 24 | grid spacing as a fraction of the container's smaller dimension. @default 1/24 |
| paused? | boolean | false | freeze the field at its reduced-motion frame. @default false |
| children? | ReactNode | — | — |
| className? | string | — | — |
| style? | CSSProperties | — | — |