Stippenveld dat op tikken antwoordt met uitdijende ringen: waar je tikt vertrekt een golf, om de zoveel seconden vertrekt er vanzelf één, en bij de eerste verf is er al één onderweg. Canvas, nul dependencies, kleur = currentColor (default de merkkleur). Slaapt zonder ringen, pauzeert buiten beeld, stil raster onder reduced motion. Kinderen renderen erbovenop.
Signalen, geen ruis.
Tik ergens in het vlak: er vertrekt een ring.
Canvas-stippenveld in de merkkleur; elke tik stuurt een golf, om de 2,4 s vertrekt er vanzelf één. Wissel van skin: de kleur volgt.
primitives/backgrounds/sonar-grid.tsx
/**
* SonarGrid — een stippenveld dat op tikken antwoordt met uitdijende ringen:
* waar je tikt vertrekt een golf, om de zoveel seconden vertrekt er vanzelf
* één, en bij de eerste verf is er al één onderweg. Canvas, nul
* dependencies; de kleur is `currentColor` van het vlak (default --primary).
* Slaapt als er geen ring leeft, pauzeert buiten beeld en in verborgen
* tabbladen, tekent een stil raster onder reduced motion. Kinderen renderen
* bovenop het veld.
*
* Geoogst uit 21st.dev Sonar Grid (n1m4mz): `text-primary`-klasse → CSS
* `color: var(--primary)` op het canvas (getComputedStyle leest die), cn →
* classNames, React 19 `ref`-prop → forwardRef, hero-inhoud uit de demo
* (lucide, motion/react) → de demo van dit blok. De rekenkern is ongewijzigd.
*/
"use client";
import { forwardRef, useCallback, useEffect, useRef } from "react";
import type * as React from "react";
export interface SonarGridProps extends React.HTMLAttributes<HTMLDivElement> {
/** Afstand tussen stippen (px). */
spacing?: number;
/** Straal van een rustende stip (px). */
dotRadius?: number;
/** Dekking in rust (0–1); op een golffront gaat een stip naar 1. */
baseOpacity?: number;
/** Elke CSS-kleur; leeg = --primary van het project. */
color?: string;
/** Seconden tussen de vanzelf-pings; 0 = uit. */
pingEvery?: number;
/** Snelheid van het golffront (px/s). */
speed?: number;
/** Dikte van het golffront (px). */
ringWidth?: number;
/** Hoeveel een stip groeit op de golftop (0 = niet, 2 = driemaal). */
amplitude?: number;
/** Ping waar de bezoeker tikt of klikt. */
interactive?: boolean;
/** Maximaal aantal ringen tegelijk; oudste vervalt eerst. */
maxRings?: number;
/** Begin met één ring halverwege zodat de eerste verf het idee al toont. */
seedPing?: boolean;
/** Waar vanzelf-pings mogen ontstaan, als fracties [x0, y0, x1, y1]. */
pingArea?: [number, number, number, number];
}
interface Ring { x: number; y: number; born: number }
const MAX_DPR = 2;
const TAU = Math.PI * 2;
export const SonarGrid = forwardRef<HTMLDivElement, SonarGridProps>(function SonarGrid(
{ spacing = 26, dotRadius = 1.4, baseOpacity = 0.28, color, pingEvery = 2.4, speed = 260, ringWidth = 90, amplitude = 2.2, interactive = true, maxRings = 6, seedPing = true, pingArea = [0.15, 0.2, 0.85, 0.8], className, children, ...rest },
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const ringsRef = useRef<Ring[]>([]);
const refreshRef = useRef<() => void>(() => {});
// De tekenlus leest de props via deze ref: wijzigingen werken zonder herstart.
const opts = useRef({ spacing, dotRadius, baseOpacity, pingEvery, speed, ringWidth, amplitude, interactive, maxRings, seedPing, pingArea });
opts.current = { spacing, dotRadius, baseOpacity, pingEvery, speed, ringWidth, amplitude, interactive, maxRings, seedPing, pingArea };
const setHost = useCallback((node: HTMLDivElement | null) => {
hostRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}, [ref]);
useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
if (!host || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
let width = 0, height = 0, raf = 0, timer = 0, visible = true, seeded = false, stroke = "";
let nextPing = performance.now() + opts.current.pingEvery * 1000;
const readColor = () => { stroke = getComputedStyle(canvas).color; };
const addRing = (x: number, y: number, born: number) => {
readColor();
const rings = ringsRef.current;
rings.push({ x, y, born });
while (rings.length > opts.current.maxRings) rings.shift();
};
const draw = (now: number) => {
const o = opts.current;
const lifetime = (Math.hypot(width, height) + o.ringWidth) / o.speed;
ringsRef.current = ringsRef.current.filter((r) => (now - r.born) / 1000 < lifetime);
const live = ringsRef.current.map((r) => {
const age = (now - r.born) / 1000;
const radius = age * o.speed;
return { x: r.x, y: r.y, radius, reach: radius + o.ringWidth, fade: 1 - age / lifetime };
});
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = stroke;
const cols = Math.ceil(width / o.spacing) + 1;
const rows = Math.ceil(height / o.spacing) + 1;
const offsetX = (width - (cols - 1) * o.spacing) / 2;
const offsetY = (height - (rows - 1) * o.spacing) / 2;
// Eerste gang: alle rustende stippen in één pad en één vulling.
const hot: number[] = [];
ctx.globalAlpha = o.baseOpacity;
ctx.beginPath();
for (let i = 0; i < cols; i++) {
const cx = offsetX + i * o.spacing;
for (let j = 0; j < rows; j++) {
const cy = offsetY + j * o.spacing;
let energy = 0;
for (const r of live) {
if (Math.abs(cx - r.x) > r.reach || Math.abs(cy - r.y) > r.reach) continue;
const dist = Math.abs(Math.hypot(cx - r.x, cy - r.y) - r.radius);
if (dist >= o.ringWidth) continue;
const t = 1 - dist / o.ringWidth;
const k = t * t * (3 - 2 * t) * r.fade;
if (k > energy) energy = k;
}
if (energy < 0.01) { ctx.moveTo(cx + o.dotRadius, cy); ctx.arc(cx, cy, o.dotRadius, 0, TAU); }
else hot.push(cx, cy, energy);
}
}
ctx.fill();
// Tweede gang: alleen de stippen op een golffront krijgen eigen alpha en straal.
for (let k = 0; k < hot.length; k += 3) {
const energy = hot[k + 2] ?? 0;
ctx.globalAlpha = o.baseOpacity + (1 - o.baseOpacity) * energy;
ctx.beginPath();
ctx.arc(hot[k] ?? 0, hot[k + 1] ?? 0, o.dotRadius * (1 + o.amplitude * energy), 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
};
const resize = () => {
const rect = host.getBoundingClientRect();
width = Math.max(1, Math.round(rect.width));
height = Math.max(1, Math.round(rect.height));
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!seeded) {
seeded = true;
const [x0, y0, x1, y1] = opts.current.pingArea;
if (opts.current.seedPing && !reduceMotion.matches) addRing(width * (x0 + (x1 - x0) * 0.68), height * (y0 + (y1 - y0) * 0.34), performance.now() - 500);
}
draw(performance.now());
};
const scheduleIdle = (delay: number) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => tick(performance.now()), Math.max(16, delay));
};
const tick = (now: number) => {
raf = 0;
if (!visible || document.hidden) return;
if (reduceMotion.matches) { ringsRef.current = []; draw(now); return; }
const o = opts.current;
if (o.pingEvery > 0 && now >= nextPing) {
const [x0, y0, x1, y1] = o.pingArea;
addRing(width * (x0 + Math.random() * (x1 - x0)), height * (y0 + Math.random() * (y1 - y0)), now);
nextPing = now + o.pingEvery * 1000;
}
draw(now);
if (ringsRef.current.length > 0) raf = requestAnimationFrame(tick);
else if (o.pingEvery > 0) scheduleIdle(nextPing - now);
};
const wake = () => { if (!raf) { window.clearTimeout(timer); raf = requestAnimationFrame(tick); } };
refreshRef.current = () => { readColor(); nextPing = Math.min(nextPing, performance.now() + opts.current.pingEvery * 1000); wake(); };
const onDown = (e: PointerEvent) => {
if (!opts.current.interactive || reduceMotion.matches) return;
const rect = host.getBoundingClientRect();
addRing(e.clientX - rect.left, e.clientY - rect.top, performance.now());
wake();
};
const onVisibility = () => { if (!document.hidden) wake(); };
const ro = new ResizeObserver(resize);
const io = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible) wake(); }, { threshold: 0 });
const mo = new MutationObserver(() => refreshRef.current());
readColor();
resize();
ro.observe(host);
io.observe(host);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
host.addEventListener("pointerdown", onDown);
document.addEventListener("visibilitychange", onVisibility);
reduceMotion.addEventListener("change", wake);
wake();
return () => {
ro.disconnect(); io.disconnect(); mo.disconnect();
host.removeEventListener("pointerdown", onDown);
document.removeEventListener("visibilitychange", onVisibility);
reduceMotion.removeEventListener("change", wake);
cancelAnimationFrame(raf);
window.clearTimeout(timer);
refreshRef.current = () => {};
};
}, []);
// Propwijzigingen terwijl de lus slaapt tekenen meteen opnieuw.
useEffect(() => { refreshRef.current(); }, [spacing, dotRadius, baseOpacity, color, pingEvery, speed, ringWidth, amplitude, interactive, maxRings, pingArea]);
return (
<div ref={setHost} {...rest} className={["fp-sonar", interactive ? "fp-sonar--interactief" : "", className].filter(Boolean).join(" ")}>
<canvas ref={canvasRef} aria-hidden="true" className="fp-sonar__doek" style={color ? { color } : undefined} />
{children}
</div>
);
});
primitives/backgrounds/backgrounds.css
/**
* Achtergronden II — importeren in de app-root (registry-site: app/globals.css).
* Token-contract 2. Canvas-vlakken die inhoud dragen: het vlak zelf is het
* raster, de kinderen liggen erbovenop.
*/
/* ── sonar-grid ─────────────────────────────────────────────────────────── */
.fp-sonar { position: relative; isolation: isolate; overflow: hidden; width: 100%; }
.fp-sonar--interactief { cursor: crosshair; }
/* De stippenkleur is currentColor van het doek: standaard de merkkleur, via `color` te overschrijven. */
.fp-sonar__doek { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; color: var(--primary); pointer-events: none; }
/* ── noise-gradient ─────────────────────────────────────────────────────── */
.fp-noise { position: relative; width: 100%; overflow: hidden; }
.fp-noise__doek { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.fp-noise__inhoud { position: relative; z-index: 1; width: 100%; height: 100%; }