Backgrounds
Retro Grid
An 80s synthwave floor — a tiled grid laid flat in perspective, scrolling toward the viewer and dissolving into the horizon.
Preview in your theme
Loading preview…
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/retro-grid.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RetroGrid" component — an 80s synthwave
perspective floor used as a background layer. Its only dependency is a cn()
class merger (clsx + tailwind-merge). No hooks, no events, no browser APIs, no
canvas — it must stay a server component, do NOT add "use client".
Contract
- export function RetroGrid(props): props extend
Omit<React.ComponentProps<"div">, "children"> (rest props + ref spread onto
the root div) plus:
- angle?: number (default 35) — rotateX tilt of the floor in degrees. Higher
tips the far end further away: stronger convergence, fewer visible rows.
Useful range ~20–45; past ~50 the near edge crosses the perspective plane
and the floor clips itself out of view entirely.
- cellSize?: number (default 60) — grid cell size in px measured on the
untilted plane.
- speed?: number (default 3) — seconds for the grid to travel one cell
toward the viewer.
- opacity?: number (default 0.45) — opacity of the whole layer.
- lineColor?: string (default "primary") — a theme token name WITHOUT the
leading "--".
- It renders no children: it is a layer, not a wrapper. The consumer puts it in
a `relative` ancestor and writes content in a `relative` sibling, which
stacks above it with no z-index bookkeeping.
Behavior
- Root div: pointer-events-none absolute inset-0 overflow-hidden, aria-hidden.
Inline style carries opacity plus three custom properties
(--zy-retro-cell / --zy-retro-speed / --zy-retro-angle) with the consumer
`style` spread last so every one is overridable per instance.
- Three nested layers, in this order and for these reasons:
1) a mask layer (absolute inset-0) whose maskImage/WebkitMaskImage is
linear-gradient(to bottom, transparent, black 42%) — the grid dissolves
into the horizon at the top instead of ending on a hard line. Keep the
mask OFF the element that owns `perspective`: masking that node flattens
its 3D rendering context.
2) the perspective node: [perspective:200px].
3) the plane: [transform:rotateX(var(--zy-retro-angle))] with
[transform-origin:50%_0%] — hinging at the top turns the plane into a
floor receding upward to a vanishing point.
- Inside the plane sits ONE oversized div (-left-1/2, w-[200%], h-[300%],
top at minus one cell: top-[calc(var(--zy-retro-cell)*-1)]) painted with two repeating linear-gradients — `to right` for the
verticals and `to bottom` for the horizontals, each `<line> 1px, transparent
1px` — at background-size var(--zy-retro-cell) square. Oversizing matters:
once the near edge stretches under perspective the plane must still cover
the frame edge to edge.
- Infinite forward scroll is one keyframe moving the tiled div's transform
from translateY(0) to translateY(var(--zy-retro-cell)). Because the step
equals exactly one tile the loop is seamless at any tilt, and perspective
does the rest: near rows appear to race, far rows crawl. Starting the div one
cell above the hinge keeps its top edge covering the horizon for the whole
cycle. Animate transform, not background-position: background-position
repaints the whole oversized plane every frame, transform stays on the
compositor.
- The @keyframes ship inside the component via a React 19 hoisted
<style href="zyeon-retro-grid" precedence="medium"> tag — no Tailwind config
edits, and multiple instances dedupe to one style tag by href.
- prefers-reduced-motion: motion-reduce:[animation:none] on the plane. The
perspective grid stays fully rendered and only stops scrolling — never hide
it, a vanishing background leaves a blank panel.
Rendering & styling
- Semantic tokens only: the lines are var(--<lineColor>) — "primary" reads as
ink on light themes and as a glowing wire on dark ones; "border" gives a
quieter blueprint. No hex / rgb() / oklch() anywhere.
- The mask uses the `black`/`transparent` keywords, which are alpha values in
a mask, not rendered colors.
- `opacity` is the single dimmer; keep line tokens at full strength and tune
the layer instead, so the two knobs never fight.
- Merge consumer className via cn() on the root — the call site can retarget
the layer (bottom half only: top-1/2), restack it (z-0) or round it.
Customization levers
- Horizon height: the 42% stop in the mask gradient — larger pushes the
horizon down and shows less grid.
- Depth feel: the [perspective:200px] value paired with `angle`. They are
coupled — the perspective distance sets the tilt at which the near edge
crosses the camera and the whole floor vanishes (~50deg at 200px). Raise
perspective to 300–400px if you want to push `angle` higher; 400px + 25deg
is a subtly tilted plane.
- Rhythm: cellSize for density (40 px is arcade-tight, 90 px is architectural)
and speed for pace (1–2 s is a chase, 6–10 s is ambient).
- Palette: lineColor takes any token name — "primary", "border", "chart-1",
"destructive" for an alert floor.
- Glow: add a second copy of the plane behind the first with a blur filter and
lower opacity for a neon bloom, or drop a radial-gradient sun above the
horizon — both are one extra sibling div.
- Direction: reverse the keyframe (translateY(var(--zy-retro-cell)) ->
translateY(0)) to make the grid recede away from the viewer.Concepts
- Perspective floor — a flat tiled plane plus
perspective+rotateXwith a toptransform-origin; the tilt alone creates the vanishing point, no per-line math and no canvas. - Seamless tile loop — the scroll translates the tiled plane by exactly one cell, so the pattern lands back on itself every cycle and the motion has no visible seam at any tilt or cell size; animating
transforminstead ofbackground-positionkeeps it on the compositor. - Horizon mask — an alpha-only gradient mask dissolves the far end of the plane instead of cutting it, letting the grid meet whatever surface it sits on rather than a hard edge.
- Mask outside perspective — the mask is applied to a wrapper, not the perspective node, because masking an element flattens its 3D rendering context and would collapse the floor back to 2D.
- Token-named ink —
lineColornames a theme token rather than a color, so the same grid reads as blueprint ink on light themes and as a neon wire on dark ones. - Reduced-motion honesty — the grid stops scrolling but stays fully drawn under
prefers-reduced-motion; the composition never disappears, only its speed does.