backgrounds
Grid Pattern
A full-bleed SVG line-grid background layer that fades to transparent at every edge via a configurable radial mask (the 'floating canvas' look) and rules itself in on scroll-into-view with a stroke-dashoffset draw-in. Uses a single crisp-edged SVG <pattern>; optional highlight cells pulse with a faint accent so hero copy feels placed on the grid. Purely decorative and inert behind page content.
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/grid-patternUsage
Usagetsx
<GridPattern />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | number | 40 | Grid cell size in px. Default 40. |
| strokeWidth | number | 1 | SVG line width. Default 1. |
| color | string | color-mix(in oklch, var(--foreground) 15%, transparent) | CSS color for grid lines. Defaults to --foreground at 15% opacity (theme-aware, visible on both light and dark). |
| fade | boolean | FadeFocus | true | Radial-gradient mask. true = even coverage; { x, y } shifts the focal point. Default true. |
| fadeStrength | enum | medium | How aggressively the grid fades toward edges. Default "medium". |
| animate | boolean | true | Enable the draw-in stroke-dashoffset reveal on first intersection. Default true. |
| highlight | GridHighlight[] | — | Zero-indexed grid cells to overlay with a faint accent fill. |
| className | string | Extra classes on the wrapper div for positioning overrides. | |
| aria-hidden | boolean | true | Decorative by default; callers may override if the grid carries meaning. |
Source
grid-pattern.tsxtsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import styles from "./grid-pattern.module.css";
type FadeFocus = { x?: number; y?: number };
export type GridHighlight = {
col: number;
row: number;
colSpan?: number;
rowSpan?: number;
};
export interface GridPatternProps {
/** Grid cell size in px. Default 40. */
size?: number;
/** SVG line width. Default 1. */
strokeWidth?: number;
/** CSS color for grid lines. Defaults to --foreground at 15% opacity (theme-aware, visible on both light and dark). */
color?: string;
/** Radial-gradient mask. true = even coverage; { x, y } shifts the focal point. Default true. */
fade?: boolean | FadeFocus;
/** How aggressively the grid fades toward edges. Default "medium". */
fadeStrength?: "soft" | "medium" | "hard";
/** Enable the draw-in stroke-dashoffset reveal on first intersection. Default true. */
animate?: boolean;
/** Zero-indexed grid cells to overlay with a faint accent fill. */
highlight?: GridHighlight[];
/** Extra classes on the wrapper div for positioning overrides. */
className?: string;
/** Decorative by default; callers may override if the grid carries meaning. */
"aria-hidden"?: boolean;
}
const FADE_VISIBLE: Record<NonNullable<GridPatternProps["fadeStrength"]>, string> = {
soft: "85%",
medium: "60%",
hard: "40%",
};
export function GridPattern({
size = 40,
strokeWidth = 1,
color = "color-mix(in oklch, var(--foreground) 15%, transparent)",
fade = true,
fadeStrength = "medium",
animate = true,
highlight,
className = "",
"aria-hidden": ariaHidden = true,
}: GridPatternProps) {
const patternId = useId();
// Mount-gate + null-safe reduced-motion check (contract snippet) so the
// SSR/first-client render is the static base (byte-identical) and the
// draw-in only turns on after hydration for non-reduced users.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
// Animated layer (draw-in AND pulse) turns on ONLY after hydration for
// non-reduced users — keeps SSR + first client render byte-identical (no #418).
const animated = mounted && !reduce;
// Draw-in plays once, on first scroll-into-view, never replays.
const wrapRef = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
const wantsDraw = animate && animated;
useEffect(() => {
if (!wantsDraw) return;
const el = wrapRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setInView(true);
observer.disconnect(); // fire once
}
},
{ threshold: 0.1 },
);
observer.observe(el);
return () => observer.disconnect();
}, [wantsDraw]);
const drawing = wantsDraw && inView;
// Mask focal point + visible radius, plus the per-cell dash length
// (perimeter of the L-shaped tile path = 2 * size), all driven via CSS
// custom properties consumed by grid-pattern.module.css.
const fadeEnabled = fade !== false && fade !== undefined;
const focus: FadeFocus = typeof fade === "object" ? fade : {};
const cssVars = {
"--grid-dash": `${size * 2}px`,
...(fadeEnabled
? {
"--grid-fade-x": `${focus.x ?? 50}%`,
"--grid-fade-y": `${focus.y ?? 50}%`,
"--grid-fade-visible": FADE_VISIBLE[fadeStrength],
}
: {}),
} as React.CSSProperties;
return (
<div
ref={wrapRef}
aria-hidden={ariaHidden ? true : undefined}
role={ariaHidden ? undefined : "presentation"}
className={[styles.root, fadeEnabled ? styles.masked : "", className]
.filter(Boolean)
.join(" ")}
style={cssVars}
>
<svg width="100%" height="100%" shapeRendering="crispEdges">
<defs>
<pattern
id={patternId}
width={size}
height={size}
patternUnits="userSpaceOnUse"
>
{/* Two passes share one tile: vertical (right edge) staggers after
horizontal (bottom edge) for the visible two-pass build. */}
<path
d={`M ${size} 0 L ${size} ${size}`}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
className={drawing ? styles.drawV : undefined}
/>
<path
d={`M 0 ${size} L ${size} ${size}`}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
className={drawing ? styles.drawH : undefined}
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${patternId})`} />
{highlight?.map((cell, i) => (
<rect
key={i}
x={cell.col * size}
y={cell.row * size}
width={(cell.colSpan ?? 1) * size}
height={(cell.rowSpan ?? 1) * size}
// accent fill; live opacity is driven by the pulse keyframes (or the
// reduced-motion static 8%). bg-accent token via fill.
fill="var(--accent)"
className={[styles.highlight, animated ? styles.pulse : ""]
.filter(Boolean)
.join(" ")}
// Static base (SSR/first render/reduced) sits at the pulse mid-low
// 8% — identical to the reduced-motion end-state. The pulse class
// overrides opacity via keyframes once animated.
style={animated ? undefined : { opacity: 0.08 }}
/>
))}
</svg>
</div>
);
}
Dependencies
- motion
- @bottega/tokens