Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 02:47:06Get Lifetime →
loopkit
freeNew214 lines · framer-motion

Dot Matrix

A grid that ripples like it's thinking. A full-width section backed by a canvas dot matrix: a diagonal wave of brightness sweeps the grid on a loop, with an optional soft glow that follows the pointer, under a short glass-card headline.

Use it for
A section break, feature intro or secondary hero where you want quiet, technical motion instead of a flat panel.
Themes
Light + dark
License
Commercial use
Live
Signal

Every point on the grid is listening

A dot matrix that ripples with a slow travelling wave.

tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

/**
 * Full-width section backed by a canvas dot matrix: a grid of dots that brightens
 * and grows along a slow travelling diagonal wave, with an optional soft boost
 * near the pointer. A glass card holds a short headline on top.
 */

export interface BgDotMatrixProps {
  eyebrow?: string;
  headline?: string;
  subcopy?: string;
  /** Wave travel speed multiplier. */
  speed?: number;
  /** Distance between dots in px. */
  spacing?: number;
  /** Token used for the wave peak colour. */
  accent?: "signal" | "info" | "lime" | "ok";
  /** Brighten dots near the pointer. */
  mouseReact?: boolean;
  className?: string;
}

type RGB = [number, number, number];

function hexToRgb(hex: string, fallback: RGB): RGB {
  const h = hex.replace("#", "").trim();
  const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
  const n = parseInt(v, 16);
  if (Number.isNaN(n) || v.length < 6) return fallback;
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

function readVar(name: string, fallback: RGB): RGB {
  if (typeof window === "undefined") return fallback;
  const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  return raw ? hexToRgb(raw, fallback) : fallback;
}

const ACCENT_FALLBACK: Record<string, RGB> = {
  signal: [255, 90, 31],
  info: [41, 82, 255],
  lime: [200, 240, 60],
  ok: [19, 138, 75],
};

export function BgDotMatrix({
  eyebrow = "Signal",
  headline = "Every point on the grid is listening",
  subcopy = "A dot matrix that ripples with a slow travelling wave.",
  speed = 1,
  spacing = 26,
  accent = "signal",
  mouseReact = true,
  className,
}: BgDotMatrixProps) {
  const reduce = useReducedMotionSafe();
  const hostRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);

  React.useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    if (!host || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let raf = 0;
    let visible = true;
    let w = 0;
    let h = 0;
    let dpr = Math.min(window.devicePixelRatio || 1, 2);
    let base: RGB = [120, 118, 110];
    let peak: RGB = ACCENT_FALLBACK[accent];
    const mouse = { x: -9999, y: -9999, active: false };

    const applyTheme = () => {
      base = readVar("--muted-foreground", [120, 118, 110]);
      peak = readVar(`--${accent}`, ACCENT_FALLBACK[accent]);
    };
    applyTheme();
    const mo = new MutationObserver(applyTheme);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const resize = () => {
      w = host.clientWidth;
      h = host.clientHeight;
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(host);

    const io = new IntersectionObserver(([e]) => (visible = e.isIntersecting));
    io.observe(host);

    const onMove = (e: PointerEvent) => {
      const r = host.getBoundingClientRect();
      mouse.x = e.clientX - r.left;
      mouse.y = e.clientY - r.top;
      mouse.active = true;
    };
    const onLeave = () => {
      mouse.active = false;
    };
    if (mouseReact) {
      host.addEventListener("pointermove", onMove);
      host.addEventListener("pointerleave", onLeave);
    }

    const draw = (t: number) => {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      const diag = Math.hypot(w, h);
      const wavePos = (t * 0.22) % 1.6 - 0.3;
      for (let y = spacing / 2; y < h + spacing; y += spacing) {
        for (let x = spacing / 2; x < w + spacing; x += spacing) {
          const d = (x + y) / diag;
          const wave = Math.exp(-Math.pow((d - wavePos) * 5, 2));
          let boost = 0;
          if (mouse.active) {
            const md = Math.hypot(x - mouse.x, y - mouse.y);
            boost = Math.max(0, 1 - md / 130);
          }
          const amt = Math.min(1, wave * 0.9 + boost * 0.8);
          const r = 1.1 + amt * 2.1;
          const [br, bg, bb] = base;
          const [pr, pg, pb] = peak;
          const cr = br + (pr - br) * amt;
          const cg = bg + (pg - bg) * amt;
          const cb = bb + (pb - bb) * amt;
          ctx.beginPath();
          ctx.fillStyle = `rgba(${cr.toFixed(0)},${cg.toFixed(0)},${cb.toFixed(0)},${(0.18 + amt * 0.72).toFixed(3)})`;
          ctx.arc(x, y, r, 0, Math.PI * 2);
          ctx.fill();
        }
      }
    };

    let elapsed = 0;
    let last = performance.now();
    const loop = () => {
      raf = requestAnimationFrame(loop);
      if (!visible || document.hidden) return;
      const now = performance.now();
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      elapsed += dt * speed;
      draw(elapsed);
    };

    if (reduce) {
      draw(0.4);
    } else {
      loop();
    }

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      if (mouseReact) {
        host.removeEventListener("pointermove", onMove);
        host.removeEventListener("pointerleave", onLeave);
      }
    };
  }, [speed, spacing, accent, mouseReact, reduce]);

  return (
    <section className={cn("relative isolate overflow-hidden bg-background", className)}>
      <div ref={hostRef} className="absolute inset-0">
        <canvas ref={canvasRef} className="absolute inset-0" aria-hidden />
      </div>
      <div className="absolute inset-0 bg-gradient-to-b from-background/0 via-background/0 to-background/60" aria-hidden />
      <div className="relative mx-auto flex min-h-[22rem] max-w-3xl flex-col items-center justify-center px-4 py-20 text-center sm:min-h-[26rem] sm:px-6">
        <motion.div
          initial={reduce ? false : { opacity: 0, y: 14 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, amount: 0.5 }}
          transition={{ duration: 0.5 }}
          className="rounded-[2rem] border border-border/60 bg-background/55 px-8 py-10 backdrop-blur-sm sm:px-14 sm:py-12"
        >
          {eyebrow && (
            <span className="inline-flex items-center gap-2 rounded-full border border-border bg-card/80 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
              <span className="size-1.5 rounded-full bg-signal" aria-hidden />
              {eyebrow}
            </span>
          )}
          <h2 className="mt-4 font-display text-[clamp(2rem,5vw,3.4rem)] font-extrabold leading-[1.03] tracking-[-0.03em]">{headline}</h2>
          {subcopy && <p className="mt-4 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">{subcopy}</p>}
        </motion.div>
      </div>
    </section>
  );
}

export default BgDotMatrix;

How it behaves

  • →Canvas draws an evenly spaced dot grid; a Gaussian-shaped wave travels diagonally across it, lerping each dot from the muted-foreground colour to the chosen accent token as the wave passes.
  • →accent picks which design token (signal/info/lime/ok) the wave peaks at; colours are re-read from CSS variables on every .dark class toggle via a MutationObserver.
  • →When mouseReact is on, dots near the pointer brighten and grow with distance falloff; the effect clears on pointer leave.
  • →speed scales wave travel speed; spacing controls grid density.
  • →Animation pauses when the section scrolls offscreen (IntersectionObserver) or the tab is hidden, and prefers-reduced-motion renders one static frame instead of looping.

Accessibility

  • ✓Canvas is aria-hidden; the headline is a real heading and all copy stays in the accessibility tree.
  • ✓Text sits on a translucent, blurred card for contrast against the animated grid in both themes.
  • ✓Pointer reactivity is a visual enhancement only; the section carries no interactive semantics that would need a keyboard equivalent.
  • ✓Honours prefers-reduced-motion.