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