{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rolling-shutter-skew",
  "title": "Rolling Shutter Skew",
  "description": "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.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/rolling-shutter-skew/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// RollingShutterSkew — a full-bleed ambient background modeling CMOS rolling\n// shutter, the \"jello effect\" from a sensor that scans photosite rows\n// top-to-bottom over a readout period instead of capturing every row at\n// once. A vertical rule grid never sits straight: each of N virtual sensor\n// rows samples a shared pan-velocity function at its own capture time\n// (earlier rows are captured earlier than lower rows, by up to one full\n// readout period), so a straight line bends into a piecewise parallelogram\n// that keeps reshaping as the simulated pan oscillates. Skew is a pure,\n// continuous function of elapsed time — no accumulated simulation state, no\n// discrete steps, so \"alive at rest\" falls out of the formula itself rather\n// than a stepped automaton.\n//\n// EXPLICITLY NOT flyback-tear (registry/loud/flyback-tear, cut on owner\n// review — CRT family): flyback is a DISPLAY-side sync failure, a\n// discontinuous tear/roll where the beam loses lock. This is CAPTURE-side\n// and continuous by construction — every row is adjacent to its neighbor in\n// both space and capture time, so the grid always bends smoothly, it never\n// jumps or tears. If a build ever introduces a discontinuity here, that is\n// the CRT-family bug this component exists specifically to avoid.\n//\n// EXPLICITLY NOT grid-magnetic-lattice (registry/core/grid-magnetic-lattice):\n// that lattice bends toward the CURSOR via a gaussian pull field, an\n// interactive tool grid. This grid bends from a self-running pan-velocity\n// function that has nothing to do with cursor position; the optional\n// pointer kick (below) perturbs the shared velocity term, it never creates\n// a positional attractor.\n//\n// PHYSICS: pan velocity v(t) = 820*sin(2pi*t/5.8) + 140*sin(2pi*t/0.9) px/s\n// — a slow base pan plus a faster handshake wobble, periods deliberately\n// non-round so the combined phase doesn't visibly repeat on any short\n// human-noticeable cycle. (The literal capture-side deceleration a real\n// handheld rolling-shutter pan produces is only a few px of skew at 1/60s\n// readout; velocity is scaled up roughly 6x from that literal figure so the\n// bend reads clearly at showpiece scale — the Washburn-style capture-time\n// formula itself, and the readout period, are the real physical numbers,\n// only the pan amplitude is a showpiece exaggeration, same latitude\n// weld-pool takes with specular structure.) Each of N_ROWS virtual sensor\n// rows captures at captureTime(row) = t - (row/N)*T_RO, T_RO = 16.7ms (a\n// 1/60s progressive CMOS readout), and its horizontal displacement is\n// skew(row) = v(captureTime(row)) * (row/N) * T_RO — later rows accumulate\n// more elapsed readout time and therefore more displacement, which is what\n// bends a straight vertical rule into a parallelogram instead of just\n// sliding it sideways.\n//\n// TOKENS: --foreground is the only ink color, drawn at fixed low alpha; no\n// fill gradient, no --ns-accent anywhere — this is a resting ambient\n// surface, and the optional pointer kick moves the SAME velocity term the\n// ambient pan already uses, never a separate accent-tinted highlight.\n// --background clears the canvas every frame. Both read via\n// getComputedStyle(document.documentElement) only after document.fonts.ready\n// resolves, before the first paint, and re-read on a MutationObserver\n// watching documentElement's class; every path that could draw before that\n// first read (ResizeObserver, IntersectionObserver, the reduced-motion\n// branch) is gated behind a `ready` flag.\n// ---------------------------------------------------------------------------\n\nconst TAU = Math.PI * 2;\n\nconst N_ROWS = 48; // virtual sensor rows sampled across the frame height\nconst T_RO = 0.0167; // s, CMOS progressive readout period (1/60s)\nconst PAN_PERIOD = 5.8; // s, slow base pan, deliberately non-round\nconst PAN_AMP = 820; // px/s\nconst WOBBLE_PERIOD = 0.9; // s, faster handshake component\nconst WOBBLE_AMP = 140; // px/s\nconst KICK_GAIN = 0.55; // px/s of extra velocity per px/s of pointer speed\nconst KICK_MAX = 420; // px/s, clamp on the pointer kick term\nconst KICK_TAU = 0.12; // s, exponential decay of the kick — back under 5% within ~400ms\nconst STATIC_TIME = 1.45; // reduced-motion freeze: quarter into the base pan, near peak velocity\nconst LINE_ALPHA = 0.35;\nconst LINE_WIDTH = 1; // css px at dpr 1\n\nfunction panVelocity(t: number): number {\n  return PAN_AMP * Math.sin((TAU * t) / PAN_PERIOD) + WOBBLE_AMP * Math.sin((TAU * t) / WOBBLE_PERIOD);\n}\n\nfunction clamp(v: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, v));\n}\n\nexport interface RollingShutterSkewProps {\n  /** grid spacing as a fraction of the container's smaller dimension. @default 1/24 */\n  gridSpacingRatio?: number;\n  /** freeze the field at its reduced-motion frame. @default false */\n  paused?: boolean;\n  children?: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n}\n\nexport function RollingShutterSkew({\n  gridSpacingRatio = 1 / 24,\n  paused = false,\n  children,\n  className = \"\",\n  style,\n}: RollingShutterSkewProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    // token fields start empty and are assigned unconditionally from\n    // getComputedStyle before any draw path can run — no literal fallback.\n    let line = \"\";\n    let bg = \"\";\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      line = cs.getPropertyValue(\"--foreground\").trim();\n      bg = cs.getPropertyValue(\"--background\").trim();\n    };\n\n    let dpr = 1;\n    let width = 0;\n    let height = 0;\n    let sized = false;\n    let ready = false;\n    let disposed = false;\n    let visible = true;\n    let raf = 0;\n    let last = 0;\n    let simTime = 0;\n    let kickVel = 0;\n\n    let havePointer = false;\n    let lastPointerX = 0;\n    let lastPointerT = 0;\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (reduced || paused) return;\n      const now = performance.now();\n      if (havePointer) {\n        const dt = Math.max(0.001, (now - lastPointerT) / 1000);\n        const vx = (e.clientX - lastPointerX) / dt;\n        kickVel = clamp(kickVel + vx * KICK_GAIN, -KICK_MAX, KICK_MAX);\n      }\n      lastPointerX = e.clientX;\n      lastPointerT = now;\n      havePointer = true;\n    };\n    const onPointerLeave = () => {\n      havePointer = false;\n    };\n    root.addEventListener(\"pointermove\", onPointerMove);\n    root.addEventListener(\"pointerleave\", onPointerLeave);\n\n    const draw = () => {\n      if (!sized || !ready) return;\n\n      ctx.fillStyle = bg;\n      ctx.fillRect(0, 0, width, height);\n\n      const t = reduced ? STATIC_TIME : simTime;\n      const kick = reduced ? 0 : kickVel;\n\n      const spacing = Math.max(18, Math.min(width, height) * gridSpacingRatio);\n      const count = Math.ceil(width / spacing) + 2;\n      const rowH = height / N_ROWS;\n\n      ctx.strokeStyle = line;\n      ctx.globalAlpha = LINE_ALPHA;\n      ctx.lineWidth = LINE_WIDTH;\n      ctx.lineJoin = \"round\";\n      ctx.beginPath();\n\n      for (let li = -1; li < count; li++) {\n        const baseX = li * spacing;\n        for (let row = 0; row <= N_ROWS; row++) {\n          const frac = row / N_ROWS;\n          const captureTime = t - frac * T_RO;\n          const v = panVelocity(captureTime) + kick;\n          const skew = v * frac * T_RO;\n          const x = baseX + skew;\n          const y = row * rowH;\n          if (row === 0) ctx.moveTo(x, y);\n          else ctx.lineTo(x, y);\n        }\n      }\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false;\n        return;\n      }\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(window.devicePixelRatio || 1, 1.5);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      sized = true;\n    };\n\n    const loop = (now: number) => {\n      if (!visible) return;\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      simTime += dt;\n      kickVel *= Math.exp(-dt / KICK_TAU);\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (!sized) return;\n        draw();\n        if (!reduced && !paused && visible && !raf) {\n          last = 0;\n          raf = requestAnimationFrame(loop);\n        }\n      }, 150);\n    };\n\n    const ro = new ResizeObserver(onResize);\n    ro.observe(canvas);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[0]?.isIntersecting ?? true;\n        if (visible && ready && !reduced && !paused) {\n          last = 0;\n          raf = requestAnimationFrame(loop);\n        } else {\n          cancelAnimationFrame(raf);\n          raf = 0;\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(canvas);\n\n    const onVis = () => {\n      if (document.hidden) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      } else if (visible && ready && !reduced && !paused) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced || paused) draw();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      readTokens();\n      resize();\n      if (!sized) {\n        ready = true;\n        return;\n      }\n      ready = true;\n      draw();\n      if (!reduced && !paused) {\n        raf = requestAnimationFrame(loop);\n      }\n    });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [gridSpacingRatio, paused]);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full\"\n      />\n      {children ? <div className=\"relative z-[1] h-full w-full\">{children}</div> : null}\n    </div>\n  );\n}\n\nRollingShutterSkew.displayName = \"RollingShutterSkew\";\n",
      "type": "registry:ui",
      "target": "components/ui/rolling-shutter-skew.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "background",
      "hero",
      "canvas",
      "rolling-shutter",
      "camera",
      "sensor",
      "jello-effect",
      "decorative"
    ],
    "instruction": "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."
  },
  "type": "registry:ui"
}