bottega

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-pattern

Usage

Usagetsx
<GridPattern />

Props

PropTypeDefaultDescription
sizenumber40Grid cell size in px. Default 40.
strokeWidthnumber1SVG line width. Default 1.
colorstringcolor-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).
fadeboolean | FadeFocustrueRadial-gradient mask. true = even coverage; { x, y } shifts the focal point. Default true.
fadeStrengthenummediumHow aggressively the grid fades toward edges. Default "medium".
animatebooleantrueEnable the draw-in stroke-dashoffset reveal on first intersection. Default true.
highlightGridHighlight[]Zero-indexed grid cells to overlay with a faint accent fill.
classNamestringExtra classes on the wrapper div for positioning overrides.
aria-hiddenbooleantrueDecorative 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