bottega

backgrounds

Retro Grid

A synthwave perspective grid: an infinite ground plane of grid lines receding to a vanishing horizon, continuously scrolling toward the viewer. Built entirely in CSS — perspective + rotateX 3D transform tilts the plane; a background-position keyframe drives the seamless infinite scroll. A soft radial glow (--primary) blooms at the vanishing horizon. Decorative (aria-hidden, pointer-events:none). Place sibling content at z-10 above z-0.

retro-grid

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/retro-grid

Usage

Usagetsx
<RetroGrid />

Props

PropTypeDefaultDescription
cellSizenumber60Grid cell size in px. Default 60.
speednumber1.5Scroll speed in seconds per one-cell cycle. Lower = faster. Default 1.5 gives a smooth, hypnotic crawl. Keep >= 0.5.
anglenumber55Ground-plane tilt angle in degrees. Higher = shallower (more overhead); lower = steeper (more side-on). Range 20–75. Default 55.

Source

retro-grid.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./retro-grid.module.css";

export interface RetroGridProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Grid cell size in px. Default 60. */
  cellSize?: number;
  /**
   * Scroll speed in seconds per one-cell cycle. Lower = faster.
   * Default 1.5 gives a smooth, hypnotic crawl. Keep >= 0.5.
   */
  speed?: number;
  /**
   * Ground-plane tilt angle in degrees. Higher = shallower (more overhead);
   * lower = steeper (more side-on). Range 20–75. Default 55.
   */
  angle?: number;
}

/**
 * Retro Grid background layer (client component).
 *
 * A synthwave perspective grid: a ground plane of infinite grid lines
 * receding to a vanishing horizon, scrolling toward the viewer via a CSS
 * background-position keyframe on a rotateX-tilted plane.
 *
 * Purely decorative: role="presentation" + aria-hidden + pointer-events:none.
 * Place sibling content at z-10 above z-0 grid.
 * Reduced-motion: static grid (no scroll animation), horizon glow preserved.
 */
export function RetroGrid({
  cellSize = 60,
  speed = 1.5,
  angle = 55,
  className = "",
  style,
  "aria-hidden": ariaHidden = true,
  ...props
}: RetroGridProps) {
  // Mount-gate + null-safe reduced-motion check (golden contract).
  // SSR/first-client render is byte-identical (no motion, no random).
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);

  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
  const animate = mounted && !reduce;

  // Pause the scroll when the layer scrolls fully out of view.
  const layerRef = useRef<HTMLDivElement>(null);
  const [running, setRunning] = useState(true);

  useEffect(() => {
    if (!animate) return;
    const el = layerRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(
      ([entry]) => setRunning(entry.isIntersecting),
      { rootMargin: "0px" },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [animate]);

  return (
    <div
      role="presentation"
      aria-hidden={ariaHidden}
      className={[styles.root, className].filter(Boolean).join(" ")}
      style={
        {
          ...style,
          "--rg-cell": `${cellSize}px`,
          "--rg-speed": `${speed}s`,
          "--rg-angle": `${angle}deg`,
        } as React.CSSProperties
      }
      {...props}
    >
      {/* Horizon bloom — soft radial glow at the vanishing line */}
      <div className={styles.horizonGlow} aria-hidden="true" />

      {/* Perspective scene — lower portion becomes the receding ground */}
      <div ref={layerRef} className={styles.scene}>
        <div
          className={[
            styles.plane,
            animate ? styles.animated : "",
          ]
            .filter(Boolean)
            .join(" ")}
          style={
            animate
              ? { animationPlayState: running ? "running" : "paused" }
              : undefined
          }
        />
      </div>
    </div>
  );
}

Dependencies

  • motion
  • @bottega/tokens