bottega

backgrounds

Animated Grid Pattern

A decorative SVG grid background whose cells gently 'breathe' — a staggered, churning subset of cells fades a soft --primary tint in and out with an asymmetric ease (slow rise, brief hold, faster decay) so it reads as an organic pulse rather than a mechanical blink. Grid lines use --border at a low strokeOpacity; a radial edge-fade mask blends it into any background. Animation is IntersectionObserver-gated (off-screen heroes spend no cycles) and fully reduced-motion safe: the static grid alone is the fallback.

animated-grid-pattern

Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/animated-grid-pattern

Usage

Usagetsx
<AnimatedGridPattern />

Props

PropTypeDefaultDescription
widthnumber40Grid cell width in px. Default: 40.
heightnumberGrid cell height in px. Defaults to `width` (square cells).
numSquaresnumber30How many cells animate simultaneously. Default: 30.
maxOpacitynumber0.1Peak fill opacity for an active cell (0–1). Default: 0.1.
durationnumber4Single cell breath cycle in seconds. Default: 4.
repeatDelaynumber0.5Pause between breath cycles for each cell in seconds. Default: 0.5.
fadebooleantrueApply radial mask-image gradient to fade edges. Default: true.
strokeOpacitynumber0.08Grid line opacity (0–1). Default: 0.08.

Source

animated-grid-pattern.tsxtsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

export type AnimatedGridPatternProps = Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "children"
> & {
  /** Grid cell width in px. Default: 40. */
  width?: number;
  /** Grid cell height in px. Defaults to `width` (square cells). */
  height?: number;
  /** How many cells animate simultaneously. Default: 30. */
  numSquares?: number;
  /** Peak fill opacity for an active cell (0–1). Default: 0.1. */
  maxOpacity?: number;
  /** Single cell breath cycle in seconds. Default: 4. */
  duration?: number;
  /** Pause between breath cycles for each cell in seconds. Default: 0.5. */
  repeatDelay?: number;
  /** Apply radial mask-image gradient to fade edges. Default: true. */
  fade?: boolean;
  /** Grid line opacity (0–1). Default: 0.08. */
  strokeOpacity?: number;
};

type Cell = {
  /** stable identity so React can reconcile churned cells */
  key: number;
  /** grid column / row index (in cells, multiplied by width/height when drawn) */
  col: number;
  row: number;
  /** per-cell stagger so cells don't all breathe in lockstep */
  delay: number;
};

/**
 * Mulberry32 — a tiny deterministic PRNG. We use a seeded generator (not
 * Math.random) so the first client render is reproducible and we can advance
 * the churn cleanly. It is only ever called inside effects, never at first
 * paint, so SSR/CSR markup stays byte-identical.
 */
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const CHURN_MS = 2000; // every 2s, replace ~20% of active cells
const CHURN_FRACTION = 0.2;

export function AnimatedGridPattern({
  width = 40,
  height,
  numSquares = 30,
  maxOpacity = 0.1,
  duration = 4,
  repeatDelay = 0.5,
  fade = true,
  strokeOpacity = 0.08,
  className = "",
  style,
  ...props
}: AnimatedGridPatternProps) {
  const cellH = height ?? width;
  const patternId = useId();

  // ── reduced-motion + mount gate (contract snippet) ──────────────────────
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  // null-safe: treat null (SSR / first render) as reduced to avoid flash + #418
  const reduce = prefersReducedMotion === null || prefersReducedMotion;

  // Only start animating once in view AND past hydration AND motion allowed.
  const [inView, setInView] = useState(false);
  const animate = mounted && !reduce && inView;

  // ── measure container so cells fill the real bounds (post-mount only) ────
  const containerRef = useRef<HTMLDivElement>(null);
  const [dims, setDims] = useState({ w: 0, h: 0 });

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      const { width: w, height: h } = entry.contentRect;
      setDims({ w, h });
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // ── in-view gate: only spend cycles when the grid is on screen ──────────
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const io = new IntersectionObserver(
      ([entry]) => setInView(entry.isIntersecting),
      { threshold: 0 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  // ── active cells (seeded, churned) ──────────────────────────────────────
  const [cells, setCells] = useState<Cell[]>([]);
  const rngRef = useRef(mulberry32(0x9e3779b9));
  const keyRef = useRef(0);

  const cols = dims.w > 0 ? Math.ceil(dims.w / width) + 1 : 0;
  const rows = dims.h > 0 ? Math.ceil(dims.h / cellH) + 1 : 0;

  const pickCell = (): Cell => {
    const rng = rngRef.current;
    return {
      key: keyRef.current++,
      col: Math.floor(rng() * cols),
      row: Math.floor(rng() * rows),
      // stagger birth across a full cycle so they don't appear all at once
      delay: rng() * duration,
    };
  };

  // seed the initial active set once we have real dimensions + animation on
  useEffect(() => {
    if (!animate || cols === 0 || rows === 0) {
      setCells([]);
      return;
    }
    keyRef.current = 0;
    rngRef.current = mulberry32(0x9e3779b9);
    setCells(Array.from({ length: numSquares }, () => pickCell()));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [animate, cols, rows, numSquares, duration]);

  // churn: every CHURN_MS swap a random ~20% subset so the pattern never loops
  useEffect(() => {
    if (!animate || cols === 0 || rows === 0) return;
    const id = window.setInterval(() => {
      const swap = Math.max(1, Math.round(numSquares * CHURN_FRACTION));
      setCells((prev) => {
        if (prev.length === 0) return prev;
        const next = prev.slice();
        const rng = rngRef.current;
        for (let i = 0; i < swap; i++) {
          const idx = Math.floor(rng() * next.length);
          next[idx] = pickCell();
        }
        return next;
      });
    }, CHURN_MS);
    return () => window.clearInterval(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [animate, cols, rows, numSquares]);

  // edge-fade mask. mask alpha — not a UI color
  const maskImage = fade
    ? "radial-gradient(ellipse 70% 70% at 50% 50%, rgb(0 0 0 / 1), transparent)"
    : undefined;

  return (
    <div
      ref={containerRef}
      className={[
        "pointer-events-none absolute inset-0 overflow-hidden",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{
        background: "var(--background)",
        maskImage,
        WebkitMaskImage: maskImage,
        ...style,
      }}
      {...props}
      // decorative background — pin aria-hidden last so consumer props can't expose it to AT
      aria-hidden="true"
    >
      <svg
        aria-hidden="true"
        className="absolute inset-0 h-full w-full"
        style={{ color: "var(--border)" }}
      >
        <defs>
          <pattern
            id={patternId}
            width={width}
            height={cellH}
            patternUnits="userSpaceOnUse"
          >
            {/* single top + left edge → tiled into a full grid; stroke uses --border via currentColor */}
            <path
              d={`M ${width} 0 L 0 0 0 ${cellH}`}
              fill="none"
              stroke="currentColor"
              strokeOpacity={strokeOpacity}
              strokeWidth={1}
            />
          </pattern>
        </defs>

        {/* static grid lines — always rendered (the reduced-motion / SSR base) */}
        <rect width="100%" height="100%" fill={`url(#${patternId})`} />

        {/* breathing cells — only mounted when animation is active */}
        {animate &&
          cells.map((cell) => (
            <motion.rect
              key={cell.key}
              width={width - 1}
              height={cellH - 1}
              x={cell.col * width + 0.5}
              y={cell.row * cellH + 0.5}
              fill="var(--primary)"
              initial={{ opacity: 0 }}
              // asymmetric breath: rise (easeOut) → hold → faster decay (easeIn)
              animate={{ opacity: [0, maxOpacity, maxOpacity, 0] }}
              transition={{
                // breath cycle in seconds (Motion JS API) — intentionally distinct
                // from the --duration UI-transition token (~300ms, too short here)
                duration,
                // keyframe positions: rise 0→60%, hold 60→70%, decay 70→100%
                times: [0, 0.6, 0.7, 1],
                // per-segment easing → slow rise, brief hold, slightly faster decay
                ease: ["easeOut", "linear", "easeIn"],
                repeat: Infinity,
                repeatDelay,
                delay: cell.delay,
              }}
            />
          ))}
      </svg>
    </div>
  );
}

Dependencies

  • motion
  • @bottega/tokens