Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 02:46:47Get Lifetime →
loopkit
freeNew102 lines · framer-motion

Retro Grid

A floor that never stops arriving. A full-width synthwave-style section: a perspective floor grid scrolls toward the viewer under a glowing horizon line, built entirely from CSS gradients and transforms, under a short glass-card headline.

Use it for
A bold, nostalgic section break for product launches, music or creative tools where a flat panel would feel too quiet.
Themes
Light + dark
License
Commercial use
Live
Horizon

A floor that never stops arriving

A perspective grid scrolling toward you under a glowing horizon.

tsx
"use client";

import * as React from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

/**
 * Full-width synthwave-style section: a perspective floor grid scrolls toward the
 * viewer under a glowing horizon, built from CSS gradients and transforms (no canvas,
 * colours follow CSS variables automatically). A glass card holds a short headline.
 */

export interface BgRetroGridProps {
  eyebrow?: string;
  headline?: string;
  subcopy?: string;
  /** Floor scroll speed multiplier. */
  speed?: number;
  /** Grid cell size in px. */
  cellSize?: number;
  /** Token used for the horizon glow. */
  accent?: "signal" | "info" | "lime" | "ok";
  className?: string;
}

export function BgRetroGrid({
  eyebrow = "Horizon",
  headline = "A floor that never stops arriving",
  subcopy = "A perspective grid scrolling toward you under a glowing horizon.",
  speed = 1,
  cellSize = 44,
  accent = "signal",
  className,
}: BgRetroGridProps) {
  const reduce = useReducedMotionSafe();
  const hostRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(hostRef, { amount: 0.15 });
  const run = !reduce && inView;

  return (
    <section
      ref={hostRef}
      className={cn("relative isolate flex min-h-[22rem] items-center justify-center overflow-hidden bg-background sm:min-h-[26rem]", className)}
      style={{ "--rg-accent": `var(--${accent})` } as React.CSSProperties}
    >
      <div
        className="absolute inset-x-0 top-0 h-1/2"
        style={{ background: "radial-gradient(ellipse 70% 100% at 50% 100%, color-mix(in srgb, var(--rg-accent) 35%, transparent), transparent 70%)" }}
        aria-hidden
      />
      <div className="absolute inset-x-0 top-[48%] h-px" style={{ background: "var(--rg-accent)", boxShadow: "0 0 24px var(--rg-accent)" }} aria-hidden />
      <div className="absolute inset-x-0 bottom-0 h-1/2 overflow-hidden [perspective:420px]" aria-hidden>
        <motion.div
          className="absolute inset-x-[-50%] top-0 h-[200%] [transform-style:preserve-3d] [transform:rotateX(62deg)]"
          style={{
            backgroundImage:
              "linear-gradient(color-mix(in srgb, var(--rg-accent) 55%, var(--foreground) 25%) 1.5px, transparent 1.5px), linear-gradient(90deg, color-mix(in srgb, var(--rg-accent) 55%, var(--foreground) 25%) 1.5px, transparent 1.5px)",
            backgroundSize: `${cellSize}px ${cellSize}px`,
            opacity: 0.75,
          }}
          initial={{ backgroundPositionY: "0px" }}
          animate={run ? { backgroundPositionY: [`0px`, `${cellSize}px`] } : { backgroundPositionY: "0px" }}
          transition={run ? { duration: 1.1 / speed, repeat: Infinity, ease: "linear" } : { duration: 0 }}
        />
        <div
          className="absolute inset-0"
          style={{ background: "linear-gradient(to top, var(--background), transparent 32%)" }}
        />
      </div>
      <div className="absolute inset-0 bg-gradient-to-b from-background/10 via-background/0 to-background/10" aria-hidden />

      <div className="relative mx-auto flex max-w-3xl flex-col items-center px-4 py-20 text-center sm:px-6">
        <motion.div
          initial={reduce ? false : { opacity: 0, y: 14 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, amount: 0.5 }}
          transition={{ duration: 0.5 }}
          className="rounded-[2rem] border border-border/60 bg-background/55 px-8 py-10 backdrop-blur-sm sm:px-14 sm:py-12"
        >
          {eyebrow && (
            <span className="inline-flex items-center gap-2 rounded-full border border-border bg-card/80 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
              <span className="size-1.5 rounded-full" style={{ background: "var(--rg-accent)" }} aria-hidden />
              {eyebrow}
            </span>
          )}
          <h2 className="mt-4 font-display text-[clamp(2rem,5vw,3.4rem)] font-extrabold leading-[1.03] tracking-[-0.03em]">{headline}</h2>
          {subcopy && <p className="mt-4 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">{subcopy}</p>}
        </motion.div>
      </div>
    </section>
  );
}

export default BgRetroGrid;

How it behaves

  • →The floor grid is a single transformed div (rotateX) with a repeating line-gradient background whose vertical position animates in a seamless one-cell loop, reading as an endless scroll toward the viewer.
  • →A horizon glow and grid line colour both use the chosen accent token through a CSS custom property, so the whole scene re-colours instantly on theme change with no JS colour reading.
  • →cellSize sets grid density; speed scales the scroll loop duration.
  • →The loop only animates while the section is in view (framer-motion useInView) and freezes at a resting frame when prefers-reduced-motion is set.

Accessibility

  • ✓The grid, glow and horizon line are aria-hidden; only the heading and copy are in the accessibility tree.
  • ✓Headline sits on a translucent blurred card for contrast against the grid in both themes.
  • ✓Honours prefers-reduced-motion.