Pure CSS Scenes & objects #loop

Synthwave floor in pure CSS

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

  • 51 lines of CSS
  • 4 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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.

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 51 lines

.retro {
  position: fixed;
  inset: 0;
  overflow: hidden;
  perspective: 260px;
  perspective-origin: 50% 40%;
  background: linear-gradient(#12062e 0%, #3b0f5c 38%, #ff4d9d 50%, #0a0618 50.5%);
}

.sun {
  position: absolute;
  top: 14%;
  left: 50%;
  width: 160px;
  height: 160px;
  translate: -50% 0;
  border-radius: 50%;
  background: linear-gradient(#ffd34d, #ff4d9d 70%);
  mask: linear-gradient(#000 55%, transparent 55% 60%, #000 60% 70%,
        transparent 70% 77%, #000 77% 85%, transparent 85%);
  box-shadow: 0 0 60px #ff4d9d;
}

.floor {
  --cell: 40px;
  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 2px, transparent 2px) 0 0 / var(--cell) var(--cell),
    linear-gradient(90deg, #2ee6d6 2px, transparent 2px) 50% 0 / var(--cell) var(--cell);
  animation: run 0.9s linear infinite;
}

@keyframes run {
  to { transform: translateY(var(--cell)); }
}

Sass source 55 lines

$cell: 36px;

.d-grid {
  position: absolute;
  inset: 0;
  overflow: hidden;
  perspective: 260px;
  perspective-origin: 50% 40%;
  background: linear-gradient(#12062e 0%, #3b0f5c 38%, #ff4d9d 50%, #0a0618 50.5%);

  &__sun {
    position: absolute;
    top: 14%;
    left: 50%;
    width: 110px;
    height: 110px;
    translate: -50% 0;
    border-radius: 50%;
    background: linear-gradient(#ffd34d, #ff4d9d 70%);
    // the classic striped lower half
    mask: linear-gradient(#000 55%, transparent 55% 60%, #000 60% 70%, transparent 70% 77%, #000 77% 85%, transparent 85%);
    box-shadow: 0 0 60px #ff4d9d;
  }

  &__floor {
    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 live on a child that slides by exactly one cell: a transform animation runs on
    // the compositor, whereas animating background-position repaints the layer every frame.
    &::before {
      content: '';
      position: absolute;
      inset: -$cell 0 0;
      background:
        linear-gradient(#2ee6d6 2px, transparent 2px) 0 0 / #{$cell} #{$cell},
        linear-gradient(90deg, #2ee6d6 2px, transparent 2px) 50% 0 / #{$cell} #{$cell};
      animation: d-grid-run 0.9s linear infinite;
    }
  }
}

// Shift by exactly one cell so the loop is seamless.
@keyframes d-grid-run {
  to {
    transform: translateY($cell);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.