"use client";
import { useEffect, useRef, type RefObject } from "react";
import { useAnimationLoop, type Metrics } from "@/hooks/use-animation-loop";
export interface PlusGridConfig {
gapX: number;
gapY: number;
fontSize: number;
fontWeight: number;
baseOpacity: number;
color: string;
scrollSensitivity: number;
waveAmplitude: number;
waveFrequency: number;
waveSpeed: number;
waveDecay: number;
opacityVariance: number;
ambientAmplitude: number;
ambientSpeed: number;
cols?: number;
rows?: number;
}
export const PLUS_GRID_DEFAULTS: PlusGridConfig = {
gapX: 42,
gapY: 38,
fontSize: 20,
fontWeight: 400,
baseOpacity: 0.5,
color: "#a855f7",
scrollSensitivity: 0.5,
waveAmplitude: 12,
waveFrequency: 0.35,
waveSpeed: 0.045,
waveDecay: 0.96,
opacityVariance: 0.4,
ambientAmplitude: 1.5,
ambientSpeed: 0.008,
cols: undefined,
rows: undefined,
};
interface Particle {
x: number;
y: number;
col: number;
row: number;
opacityMod: number;
}
interface WaveState {
energy: number;
direction: number;
phase: number;
}
export interface PlusGridProps {
config?: Partial<PlusGridConfig>;
className?: string;
scrollContainerRef?: RefObject<HTMLElement | null>;
pointerEnergy?: boolean;
autoPulse?: boolean;
paused?: boolean;
}
export default function PlusGrid({
config: userConfig,
className,
scrollContainerRef,
pointerEnergy = true,
autoPulse = true,
paused = false,
}: PlusGridProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const merged: PlusGridConfig = { ...PLUS_GRID_DEFAULTS, ...userConfig };
const cfgRef = useRef<PlusGridConfig>(merged);
cfgRef.current = merged;
const flagsRef = useRef({ pointerEnergy, paused });
flagsRef.current = { pointerEnergy, paused };
const waveRef = useRef<WaveState>({ energy: 0, direction: 1, phase: 0 });
const rebuildRef = useRef<(() => void) | null>(null);
const drawRef = useRef<(() => void | false) | null>(null);
const measureRef = useRef<((m: Metrics) => void) | null>(null);
const loop = useAnimationLoop({
target: containerRef,
halted: paused,
dpr: "auto",
onResize: (metrics) => measureRef.current?.(metrics),
onFrame: () => (drawRef.current ? drawRef.current() : false),
});
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let particles: Particle[] = [];
let ambientPhase = 0;
let fontFamily = "ui-monospace";
let dpr = 1;
const wave = waveRef.current;
const scroller = scrollContainerRef?.current ?? null;
let lastScrollY = scroller ? scroller.scrollTop : window.scrollY;
let lastPointerY: number | null = null;
const buildParticles = (w: number, h: number) => {
const { gapX, gapY, cols: manualCols, rows: manualRows } = cfgRef.current;
const cols = manualCols ?? Math.max(1, Math.floor(w / gapX));
const rows = manualRows ?? Math.max(1, Math.floor(h / gapY));
const offsetX = (w - (cols - 1) * gapX) / 2;
const offsetY = (h - (rows - 1) * gapY) / 2;
particles = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
particles.push({
x: offsetX + c * gapX,
y: offsetY + r * gapY,
col: c,
row: r,
opacityMod:
Math.random() > 0.45
? 0.9 + Math.random() * 0.1
: 0.08 + Math.random() * 0.15,
});
}
}
};
measureRef.current = ({ width: w, height: h, dpr: nextDpr, bufferWidth, bufferHeight }) => {
dpr = nextDpr;
canvas.width = bufferWidth;
canvas.height = bufferHeight;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
fontFamily =
getComputedStyle(canvas).getPropertyValue("--font-display").trim() ||
"ui-monospace";
buildParticles(w, h);
};
const injectEnergy = (delta: number, sensitivity: number) => {
if (Math.abs(delta) < 0.5) return;
wave.energy = Math.min(
1,
wave.energy + Math.abs(delta) * cfgRef.current.scrollSensitivity * sensitivity,
);
wave.direction = delta > 0 ? 1 : -1;
};
const onScroll = () => {
const y = scroller ? scroller.scrollTop : window.scrollY;
injectEnergy(y - lastScrollY, 0.038);
lastScrollY = y;
};
const onPointerMove = (e: PointerEvent) => {
if (!flagsRef.current.pointerEnergy || flagsRef.current.paused) return;
if (lastPointerY !== null) injectEnergy(e.clientY - lastPointerY, 0.02);
lastPointerY = e.clientY;
};
const onPointerLeave = () => {
lastPointerY = null;
};
const scrollTarget: HTMLElement | Window = scroller ?? window;
scrollTarget.addEventListener("scroll", onScroll, { passive: true });
container.addEventListener("pointermove", onPointerMove, { passive: true });
container.addEventListener("pointerleave", onPointerLeave, {
passive: true,
});
const tick = () => {
const cfg = cfgRef.current;
const isPaused = flagsRef.current.paused;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.clearRect(0, 0, w, h);
if (!isPaused) {
wave.energy = wave.energy * cfg.waveDecay;
wave.phase += cfg.waveSpeed;
ambientPhase += cfg.ambientSpeed;
}
ctx.font = `${cfg.fontWeight} ${cfg.fontSize}px ${fontFamily}, ui-monospace, SFMono-Regular, monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = cfg.color;
for (const p of particles) {
const spatialArg = p.col * cfg.waveFrequency + wave.phase;
const scrollY =
Math.sin(spatialArg) *
cfg.waveAmplitude *
wave.energy *
wave.direction;
const idleY =
Math.sin(p.col * 0.3 + p.row * 0.4 + ambientPhase) *
cfg.ambientAmplitude;
const opacityDelta =
Math.cos(spatialArg) * cfg.opacityVariance * wave.energy;
const opacity = Math.max(
0.04,
Math.min(0.95, cfg.baseOpacity * p.opacityMod + opacityDelta),
);
ctx.globalAlpha = opacity;
ctx.fillText("+", p.x, p.y + scrollY + idleY);
}
ctx.globalAlpha = 1;
if (isPaused) return false;
};
drawRef.current = tick;
loop.resize();
loop.start();
rebuildRef.current = () => {
const w = container.clientWidth;
const h = container.clientHeight;
if (w > 0 && h > 0) buildParticles(w, h);
};
return () => {
drawRef.current = null;
measureRef.current = null;
scrollTarget.removeEventListener("scroll", onScroll);
container.removeEventListener("pointermove", onPointerMove);
container.removeEventListener("pointerleave", onPointerLeave);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!autoPulse || paused) return;
const id = setInterval(() => {
const wave = waveRef.current;
wave.energy = Math.max(wave.energy, 0.7);
wave.direction *= -1;
}, 2800);
return () => clearInterval(id);
}, [autoPulse, paused]);
useEffect(() => {
rebuildRef.current?.();
}, [merged.gapX, merged.gapY, merged.cols, merged.rows]);
return (
<div ref={containerRef} className={className ?? "h-full w-full"}>
<canvas
ref={canvasRef}
className="absolute inset-0 size-full touch-none"
style={{ display: "block" }}
/>
</div>
);
}