Skip to main content

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.

Usage

registry/loud/rolling-shutter-skew/demo.tsx
"use client";

import { RollingShutterSkew } from "./component";

// The grid fills the whole viewport and is already mid-bend before anything
// is touched — the pan oscillates on its own, the pointer only nudges the
// same shared velocity term it already runs on.
export default function RollingShutterSkewDemo() {
  return (
    <main className="h-screen w-full overflow-hidden bg-background">
      <RollingShutterSkew>
        <div className="pointer-events-none flex h-full w-full flex-col items-center justify-center gap-4 px-6 text-center">
          <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 / rolling-shutter-skew
            </p>
            <h1 className="text-2xl font-medium tracking-tight text-foreground sm:text-3xl">
              Every row, a moment later
            </h1>
            <p className="max-w-sm text-sm text-ns-muted">
              A row-by-row sensor readout bends a straight grid into a
              shifting parallelogram as the simulated pan sweeps back and
              forth.
            </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>
        </div>
      </RollingShutterSkew>
    </main>
  );
}

Component

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";

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

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.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 <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

PropTypeDefaultDescription
gridSpacingRatio?number1 / 24grid spacing as a fraction of the container's smaller dimension. @default 1/24
paused?booleanfalsefreeze the field at its reduced-motion frame. @default false
children?ReactNode——
className?string——
style?CSSProperties——