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-patternUsage
Usagetsx
<AnimatedGridPattern />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| width | number | 40 | Grid cell width in px. Default: 40. |
| height | number | — | Grid cell height in px. Defaults to `width` (square cells). |
| numSquares | number | 30 | How many cells animate simultaneously. Default: 30. |
| maxOpacity | number | 0.1 | Peak fill opacity for an active cell (0–1). Default: 0.1. |
| duration | number | 4 | Single cell breath cycle in seconds. Default: 4. |
| repeatDelay | number | 0.5 | Pause between breath cycles for each cell in seconds. Default: 0.5. |
| fade | boolean | true | Apply radial mask-image gradient to fade edges. Default: true. |
| strokeOpacity | number | 0.08 | Grid 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