Pure CSS Scenes & objects #loop

Synthwave floor in pure CSS

A gradient-drawn grid laid flat with rotateX, scrolling toward you forever.

  • 56 lines of CSS
  • 4 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The floor is one big element with two linear-gradients drawing the grid lines.
  2. Hinge it on the horizon (transform-origin: top) and lay it down with rotateX(80deg). A short perspective on the parent exaggerates the depth.
  3. Motion is a child layer sliding by exactly one cell with transform, so the loop is seamless. (Animating background-position looks the same but repaints every frame; transform does not.)
  4. A mask gradient fades the lines out toward the horizon.
  5. The sun stands on the horizon (bottom: 50%, the horizon's own 50%) and is only the part above it. Its circle is a clip-path and its slices a mask, both on the sun itself, so the slices stay inside the outline on every canvas.
  6. The scene is inset: 0, so it fills the canvas edge to edge whatever its shape. The sun, the cell and the perspective are multiples of one base unit, --u, tied to the canvas, so the horizon looks the same on a gallery card and on a full screen.

Key techniques

  • perspective on parent
  • rotateX floor plane
  • line layer slid with transform (not background-position)

Copy-paste code

HTML 4 lines

<div class="retro">
  <div class="sun"></div>
  <div class="floor"></div>
</div>

CSS 56 lines

.retro {
  /* one base unit, tied to the canvas; the scene itself fills the canvas edge to edge */
  --u: 0.33vmin;
  position: fixed;
  inset: 0;
  overflow: hidden;
  perspective: calc(260 * var(--u));
  perspective-origin: 50% 40%;
  background: linear-gradient(#12062e 0%, #3b0f5c 38%, #ff4d9d 50%, #0a0618 50.5%);
}

/* the sun stands ON the horizon (bottom: 50%, the same 50% as the horizon line) and is only the
   part above it: a 160-unit circle sunk 40 units, so nothing of it can reach the floor */
.sun {
  position: absolute;
  bottom: 50%;
  left: 50%;
  width: calc(160 * var(--u));
  height: calc(120 * var(--u));
  translate: -50% 0;
  background: linear-gradient(#ffd34d, #ff4d9d 93%);
  /* the circle is a clip and the slices a mask, both on the sun itself, so the slices scale
     with it and stay inside the outline on every canvas; their gaps grow toward the horizon */
  clip-path: circle(calc(80 * var(--u)) at 50% calc(80 * var(--u)));
  mask: linear-gradient(#000 50%, transparent 50% 52%, #000 52% 63%, transparent 63% 67%,
        #000 67% 76%, transparent 76% 82%, #000 82% 89%, transparent 89% 97%, #000 97%);
}

.floor {
  --cell: calc(40 * var(--u));
  position: absolute;
  top: 50%;
  left: -100%;
  width: 300%;
  height: 300%;
  transform-origin: top center;
  transform: rotateX(80deg);
  overflow: hidden;
  mask: linear-gradient(transparent, #000 12%);
}

/* the lines slide on a child: transform animates on the compositor,
   background-position would repaint the whole layer every frame */
.floor::before {
  content: '';
  position: absolute;
  inset: calc(var(--cell) * -1) 0 0;
  background:
    linear-gradient(#2ee6d6 calc(2 * var(--u)), transparent calc(2 * var(--u))) 0 0 / var(--cell) var(--cell),
    linear-gradient(90deg, #2ee6d6 calc(2 * var(--u)), transparent calc(2 * var(--u))) 50% 0 / var(--cell) var(--cell);
  animation: run 0.9s linear infinite;
}

@keyframes run {
  to { transform: translateY(var(--cell)); }
}
Standalone snippet · plain CSS, no build step, no dependencies.