{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "noise-grid",
  "type": "registry:ui",
  "title": "Noise Grid",
  "description": "An editorial hairline grid with film grain and accent cells that pulse and wander.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/noise-grid.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface NoiseGridProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Grid cell size, in px. */\n  spacing?: number;\n  /** Film-grain strength, 0–1. 0 removes the noise layer. */\n  grain?: number;\n  /** Strength of the highlighted cells, 0–1. */\n  accentIntensity?: number;\n  /** Number of highlighted cells drifting across the grid. */\n  highlights?: number;\n  /** Length of one highlight pulse, in ms. */\n  duration?: number;\n  /** Accent colour (any CSS colour). */\n  accent?: string;\n  /** Fade the grid out toward the edges. */\n  fade?: boolean;\n}\n\n// Fractal noise rasterised once as a 160px tile; alpha comes from the noise, colour is solid,\n// so the grain textures both light and dark surfaces without shifting their tone.\nconst grainTile = (rgb: string) =>\n  `url(\"data:image/svg+xml,${encodeURIComponent(\n    `<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 ${rgb} 0 0 0 0 ${rgb} 0 0 0 0 ${rgb} 1.4 0 0 0 -0.45'/></filter><rect width='100%' height='100%' filter='url(#g)'/></svg>`,\n  )}\")`;\n\nconst GRAIN_DARK_SPECKS = grainTile(\"0\");\nconst GRAIN_LIGHT_SPECKS = grainTile(\"1\");\n\nconst subscribeReducedMotion = (onChange: () => void) => {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n};\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/**\n * An editorial background: hairline grid, film grain and a few accent cells that pulse\n * and wander to neighbouring cells. Only opacity and transform are animated, with a\n * handful of elements — no canvas, no per-frame JavaScript.\n */\nexport function NoiseGrid({\n  spacing = 48,\n  grain = 0.35,\n  accentIntensity = 0.6,\n  highlights = 5,\n  duration = 4200,\n  accent = \"#FF7448\",\n  fade = true,\n  className = \"\",\n  style,\n  children,\n  ...props\n}: NoiseGridProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const cellRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const reduced = usePrefersReducedMotion();\n  const count = Math.max(0, Math.min(24, Math.round(highlights)));\n  const size = Math.max(8, spacing);\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const cells = cellRefs.current;\n    if (!root) return;\n\n    const grid = { cols: 1, rows: 1 };\n    const positions: { col: number; row: number }[] = [];\n    const animations: (Animation | null)[] = [];\n    let inView = true;\n    let cancelled = false;\n\n    const measure = () => {\n      const rect = root.getBoundingClientRect();\n      grid.cols = Math.max(1, Math.floor(rect.width / size));\n      grid.rows = Math.max(1, Math.floor(rect.height / size));\n    };\n\n    const place = (i: number, col: number, row: number) => {\n      positions[i] = { col, row };\n      const el = cellRefs.current[i];\n      if (el) el.style.transform = `translate3d(${col * size}px, ${row * size}px, 0)`;\n    };\n\n    const randomCell = () => ({ col: Math.floor(Math.random() * grid.cols), row: Math.floor(Math.random() * grid.rows) });\n\n    // Drift: each pulse lands on a neighbouring cell, so highlights read as wandering, not blinking.\n    const step = (i: number) => {\n      const prev = positions[i] ?? randomCell();\n      const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1]];\n      const [dc, dr] = dirs[Math.floor(Math.random() * dirs.length)];\n      const col = (prev.col + dc + grid.cols) % grid.cols;\n      const row = (prev.row + dr + grid.rows) % grid.rows;\n      place(i, col, row);\n    };\n\n    const run = (i: number, delay: number) => {\n      const el = cellRefs.current[i];\n      if (!el || cancelled || typeof el.animate !== \"function\") return;\n      step(i);\n      const anim = el.animate(\n        [\n          { opacity: 0, offset: 0 },\n          { opacity: 1, offset: 0.35 },\n          { opacity: 1, offset: 0.55 },\n          { opacity: 0, offset: 1 },\n        ],\n        { duration, delay, easing: \"cubic-bezier(0.45, 0, 0.55, 1)\", fill: \"backwards\" },\n      );\n      animations[i] = anim;\n      if (!inView || document.hidden) anim.pause();\n      // A short random rest keeps the cells from drifting into lockstep.\n      anim.onfinish = () => run(i, Math.random() * duration * 0.35);\n    };\n\n    measure();\n    for (let i = 0; i < count; i++) {\n      const { col, row } = randomCell();\n      place(i, col, row);\n    }\n\n    if (reduced) {\n      // Still composition: cells shown at rest, no animation.\n      cellRefs.current.forEach((el) => {\n        if (el) el.style.opacity = \"0.75\";\n      });\n    } else {\n      for (let i = 0; i < count; i++) run(i, (i * duration) / Math.max(1, count));\n    }\n\n    const sync = () => {\n      const play = inView && !document.hidden;\n      for (const a of animations) {\n        if (!a) continue;\n        if (play && a.playState === \"paused\") a.play();\n        else if (!play && a.playState === \"running\") a.pause();\n      }\n    };\n\n    const ro = new ResizeObserver(() => {\n      measure();\n      // Keep cells inside the grid when it shrinks.\n      positions.forEach((p, i) => place(i, Math.min(p.col, grid.cols - 1), Math.min(p.row, grid.rows - 1)));\n    });\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      sync();\n    });\n    ro.observe(root);\n    io.observe(root);\n    document.addEventListener(\"visibilitychange\", sync);\n\n    return () => {\n      cancelled = true;\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      cells.forEach((el) => {\n        if (el) el.style.opacity = \"\";\n      });\n      for (const a of animations) {\n        if (!a) continue;\n        a.onfinish = null;\n        a.cancel();\n      }\n    };\n  }, [count, size, duration, reduced]);\n\n  const intensity = Math.min(Math.max(accentIntensity, 0), 1);\n  const grainOpacity = Math.min(Math.max(grain, 0), 1) * 0.45;\n  const mask = fade ? \"radial-gradient(ellipse 75% 70% at 50% 45%, #000 35%, transparent 100%)\" : undefined;\n  const major = size * 4;\n\n  return (\n    <div\n      ref={rootRef}\n      className={`absolute inset-0 overflow-hidden [--ng-line:rgba(24,24,27,0.075)] [--ng-major:rgba(24,24,27,0.13)] dark:[--ng-line:rgba(255,255,255,0.06)] dark:[--ng-major:rgba(255,255,255,0.11)] ${className}`}\n      style={{ [\"--ng-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" style={{ maskImage: mask, WebkitMaskImage: mask }}>\n        {/* Hairline grid with a stronger major line every four cells. */}\n        <div\n          className=\"absolute inset-0\"\n          style={{\n            backgroundImage: [\n              \"linear-gradient(to right, var(--ng-major) 1px, transparent 1px)\",\n              \"linear-gradient(to bottom, var(--ng-major) 1px, transparent 1px)\",\n              \"linear-gradient(to right, var(--ng-line) 1px, transparent 1px)\",\n              \"linear-gradient(to bottom, var(--ng-line) 1px, transparent 1px)\",\n            ].join(\",\"),\n            backgroundSize: `${major}px ${major}px, ${major}px ${major}px, ${size}px ${size}px, ${size}px ${size}px`,\n          }}\n        />\n        {Array.from({ length: count }, (_, i) => (\n          <div\n            key={i}\n            ref={(el) => {\n              cellRefs.current[i] = el;\n            }}\n            className=\"absolute left-0 top-0 opacity-0 will-change-[transform,opacity]\"\n            style={{\n              width: size + 1,\n              height: size + 1,\n              background: `color-mix(in srgb, var(--ng-accent) ${Math.round(intensity * 26)}%, transparent)`,\n              boxShadow: `inset 0 0 0 1px color-mix(in srgb, var(--ng-accent) ${Math.round(intensity * 80)}%, transparent)`,\n            }}\n          >\n            {/* Registration tick in the corner — a small editorial detail. */}\n            <span\n              className=\"absolute left-0 top-0 size-[3px]\"\n              style={{ background: `color-mix(in srgb, var(--ng-accent) ${Math.round(40 + intensity * 60)}%, transparent)` }}\n            />\n          </div>\n        ))}\n      </div>\n      {grainOpacity > 0 && (\n        <>\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 dark:hidden\"\n            style={{ backgroundImage: GRAIN_DARK_SPECKS, backgroundSize: \"160px 160px\", opacity: grainOpacity }}\n          />\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 hidden dark:block\"\n            style={{ backgroundImage: GRAIN_LIGHT_SPECKS, backgroundSize: \"160px 160px\", opacity: grainOpacity * 0.7 }}\n          />\n        </>\n      )}\n      {children != null && <div className=\"relative h-full w-full\">{children}</div>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/noise-grid"
}
