← alle entries

sonar-grid

FC-0149Achtergrondensonar-gridprimitivev1.0.0

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.

zelfde component — andere tokens

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%; }