A true 3D floor of glowing lines that recedes forever to a lit horizon, with an optional ceiling for a tunnel.
To infinity
Customize
Usage
import { PerspectiveGrid } from "@/components/motionkit/perspective-grid";
<PerspectiveGrid
className="h-[32rem] rounded-3xl pt-24 text-center"
>
<h1>Your hero</h1>
</PerspectiveGrid>Motion
The exact curves this component runs, plotted with Motion's own maths.
One square closer
Runs on its own
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/perspective-grid.tsx.
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type PerspectiveGridProps = {
children?: ReactNode;
/** Grid line colour as a hex value. */
color?: string;
/** Size of the nearest squares, in pixels. */
spacing?: number;
/** Squares travelled per second toward the viewer. 0 holds the grid still. */
speed?: number;
/** Strength of the horizon glow and the lines, 0 to 1. */
intensity?: number;
/** Height of the horizon, from 0 (top) to 1 (bottom). */
horizon?: number;
/** Mirror the floor as a ceiling, for a tunnel. */
mirror?: boolean;
className?: string;
};
function toRgb(hex: string) {
const value = hex.replace("#", "");
const full = value.length === 3 ? [...value].map((c) => c + c).join("") : value;
const number = parseInt(full, 16);
return `${(number >> 16) & 255} ${(number >> 8) & 255} ${number & 255}`;
}
export function PerspectiveGrid({
children,
color = "#A855F7",
spacing = 48,
speed = 1,
intensity = 0.7,
horizon = 0.5,
mirror = false,
className = "",
}: PerspectiveGridProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
const rgb = toRgb(color);
const still = speed === 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let frame = 0;
let visible = true;
const startedAt = performance.now();
/*
* A camera one unit above an endless floor. A floor point `z` units away lands
* `focal / z` pixels below the horizon, so lines bunch up as they recede, as real ones do.
* The focal length puts z = 1 at the canvas edge, which makes one floor unit `focal` pixels
* wide there: a square of `spacing` pixels is spacing / focal units.
*/
function drawPlane(phase: number, horizonY: number, focal: number, direction: 1 | -1) {
const cell = spacing / focal;
const centerX = width / 2;
const edgeY = horizonY + direction * focal;
const alpha = 0.25 + intensity * 0.6;
// depth lines, nearest first; stop once they are packed tighter than a pixel apart
context!.lineWidth = 1;
for (let k = 0; k < 400; k++) {
const z = 1 + k * cell - phase * cell;
if (z <= 0.05) continue;
const offset = focal / z;
if (offset < 1) break;
const fade = Math.min(offset / focal, 1);
context!.strokeStyle = `rgb(${rgb} / ${alpha * Math.pow(fade, 0.55)})`;
context!.beginPath();
context!.moveTo(0, horizonY + direction * offset);
context!.lineTo(width, horizonY + direction * offset);
context!.stroke();
}
// lines running into the distance: every one aims at the same vanishing point
const gradient = context!.createLinearGradient(0, edgeY, 0, horizonY);
gradient.addColorStop(0, `rgb(${rgb} / ${alpha})`);
gradient.addColorStop(1, `rgb(${rgb} / 0)`);
context!.strokeStyle = gradient;
// lines far to the side only enter the frame higher up, so draw well past the edges
const reach = Math.ceil((width / 2 / spacing) * 6);
for (let n = -reach; n <= reach; n++) {
context!.beginPath();
context!.moveTo(centerX + n * spacing, edgeY);
context!.lineTo(centerX, horizonY);
context!.stroke();
}
}
function draw(now: number) {
context!.clearRect(0, 0, width, height);
const horizonY = height * horizon;
const phase = still ? 0 : (((now - startedAt) / 1000) * speed) % 1;
drawPlane(phase, horizonY, height - horizonY, 1);
if (mirror) drawPlane(phase, horizonY, horizonY, -1);
}
function loop(now: number) {
draw(now);
frame = visible && !still ? requestAnimationFrame(loop) : 0;
}
function resize() {
const ratio = window.devicePixelRatio || 1;
const rect = canvas!.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas!.width = width * ratio;
canvas!.height = height * ratio;
context!.setTransform(ratio, 0, 0, ratio, 0, 0);
if (!frame) draw(performance.now());
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(canvas);
// nothing to draw while it is off screen
const visibilityObserver = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible && !frame && !still) frame = requestAnimationFrame(loop);
});
visibilityObserver.observe(canvas);
resize();
if (!still) frame = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(frame);
resizeObserver.disconnect();
visibilityObserver.disconnect();
};
}, [color, spacing, speed, intensity, horizon, mirror]);
return (
<div className={`relative isolate overflow-hidden bg-[#0B0A12] ${className}`}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 -z-10 size-full" />
{/* the glow where the floor meets the sky, and a bright seam along the horizon itself */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -z-10 h-[60%] -translate-y-1/2"
style={{
top: `${horizon * 100}%`,
background: `radial-gradient(ellipse 60% 50% at 50% 50%, ${color}, transparent 70%)`,
opacity: intensity * 0.55,
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -z-10 h-px"
style={{
top: `${horizon * 100}%`,
background: `linear-gradient(to right, transparent, ${color}, transparent)`,
boxShadow: `0 0 24px 2px ${color}`,
opacity: intensity,
}}
/>
{children}
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| color | string | "#A855F7" | Line and glow colour as a hex value. |
| spacing | number | 48 | Size of the nearest squares in pixels. |
| speed | number | 1 | Squares per second toward the viewer. 0 holds still. |
| intensity | number | 0.7 | Strength of the lines and horizon glow, 0 to 1. |
| horizon | number | 0.5 | Horizon height, 0 (top) to 1 (bottom). |
| mirror | boolean | false | Add a ceiling, for a tunnel. |
| children | ReactNode | — | Your content, layered above. |
Into the future
Horizon Grid
A retro perspective grid rolling toward a glowing horizon, synthwave style.
Ambient by default
Aurora Background
Drifting, blurred color fields for hero sections and empty states.
Move your cursor
Dot Glow Background
A quiet dot grid that lights up in teal around the pointer.