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-gridUsage
Usagetsx
<RetroGrid />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| cellSize | number | 60 | Grid cell size in px. Default 60. |
| speed | number | 1.5 | Scroll speed in seconds per one-cell cycle. Lower = faster. Default 1.5 gives a smooth, hypnotic crawl. Keep >= 0.5. |
| angle | number | 55 | Ground-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