Pure CSS Scenes & objects #loop #scene #road #synthwave

Endless drive in pure CSS

A road plane laid flat with rotateX(90deg). The centre line slides by exactly one dash period, and posts travel the whole road with staggered delays, so the loop never shows a seam.

  • 104 lines of CSS
  • 20 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed
GitHub

How it works

  1. The road is one long plane (1400px) folded flat with transform-origin: bottom; rotateX(90deg). Put perspective-origin on the horizon line, and the far end vanishes exactly there.
  2. Nothing really moves forward. The centre line is one period (100px) taller than the road and slides down by exactly that period, then jumps back: the jump is invisible because the pattern looks identical.
  3. Posts stand up with rotateX(-90deg) around their foot and all run the same trip along the road, spread out by negative delays. Speed matches the dashes: 1400px in 7s = 100px in 0.5s.
  4. A mask on the road would flatten its 3D posts. Instead a flat band of haze sits on top of the horizon and hides where things appear. The ground lines are a repeating-conic-gradient from the vanishing point.

Key techniques

  • rotateX(90deg) floor + perspective-origin = horizon
  • translate by one pattern period
  • upright posts: rotateX(-90deg), origin bottom
  • flat haze overlay instead of a mask

Copy-paste code

HTML 20 lines

<div class="drive">
  <div class="sun"></div>
  <div class="ground"></div>
  <div class="road">
    <div class="dashes"></div>
    <i class="left" style="--i:0"></i>
    <i class="right" style="--i:0"></i>
    <i class="left" style="--i:1"></i>
    <i class="right" style="--i:1"></i>
    <i class="left" style="--i:2"></i>
    <i class="right" style="--i:2"></i>
    <i class="left" style="--i:3"></i>
    <i class="right" style="--i:3"></i>
    <i class="left" style="--i:4"></i>
    <i class="right" style="--i:4"></i>
    <i class="left" style="--i:5"></i>
    <i class="right" style="--i:5"></i>
  </div>
  <div class="haze"></div>
</div>

CSS 104 lines

.drive {
  position: fixed;
  inset: 0;
  overflow: hidden;
  perspective: 300px;
  perspective-origin: 50% 44%; /* the horizon */
  background: linear-gradient(#07051a, #3a2580 24%, #8c2a6c 44%);
}

.sun {
  position: absolute;
  top: calc(44% - 78px);
  left: calc(50% - 60px);
  width: 120px;
  height: 120px;
  border-radius: 50%;
  background: linear-gradient(#ffb547 20%, #ff4d9d 75%);
  box-shadow: 0 0 40px rgb(255 77 157 / 0.6);
  mask: linear-gradient(#000 30%, transparent 0 33%, #000 0 41%, transparent 0 45%, #000 0 52%, transparent 0 57%, #000 0);
}

/* lines from the vanishing point: parallel to the road, so they stay still */
.ground {
  position: absolute;
  inset: 44% 0 0;
  background:
    repeating-conic-gradient(from 90deg at 50% 0%, rgb(255 77 157 / 0.5) 0 0.5deg, transparent 0.5deg 9deg),
    linear-gradient(#3b1a6e, #06030f 70%);
}

.road {
  position: absolute;
  left: 29%;
  right: 29%;
  bottom: 0;
  height: 1400px;
  background: linear-gradient(90deg, #ff4d9d 0 3px, #1c1438 3px calc(100% - 3px), #ff4d9d calc(100% - 3px));
  transform-style: preserve-3d;
  transform-origin: bottom;
  /* the near end starts 120px in front of the screen (well below the 300px perspective) */
  transform: translateZ(120px) rotateX(90deg);
}

.dashes {
  position: absolute;
  top: -100px; /* one period taller than the road */
  bottom: 0;
  left: calc(50% - 3px);
  width: 6px;
  background: repeating-linear-gradient(#ffb547 0 50px, transparent 50px 100px);
  animation: dash 0.5s linear infinite;
}

.road i {
  position: absolute;
  bottom: 0;
  width: 5px;
  height: 46px;
  border-radius: 3px;
  background: linear-gradient(#2ee6d6, rgb(46 230 214 / 0.15));
  box-shadow: 0 0 8px rgb(46 230 214 / 0.7);
  transform-origin: bottom;
  animation: post 7s linear infinite;
  animation-delay: calc(var(--i) * -7s / 6);
}

.road i::before {
  content: '';
  position: absolute;
  top: -4px;
  left: -3px;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: radial-gradient(circle, #fff 0 25%, #2ee6d6 60%);
}

.road .left {
  left: -22px;
}

.road .right {
  right: -22px;
  animation-delay: calc(var(--i) * -7s / 6 - 7s / 12); /* half a step later */
}

.haze {
  position: absolute;
  inset: calc(44% - 7%) 0 auto;
  height: 22%;
  background: linear-gradient(transparent, #8c2a6c 30% 50%, transparent);
}

/* translateZ(1px): paint just above the road surface */
@keyframes dash {
  from { transform: translateZ(1px) translateY(0); }
  to   { transform: translateZ(1px) translateY(100px); }
}

@keyframes post {
  from { opacity: 0; transform: translateY(-1400px) rotateX(-90deg); }
  12%  { opacity: 1; }
  to   { opacity: 1; transform: translateY(0) rotateX(-90deg); }
}

Sass source 149 lines

@use 'sass:math';

// A synthwave drive. The road is one long plane laid flat with rotateX(90deg) around its
// bottom edge; perspective-origin sits on the horizon, so the far end vanishes exactly there.
// Nothing really travels: the centre line slides by ONE dash period and jumps back (seamless),
// and the posts all run the same trip, spread along the road by negative delays.
$horizon: 44%;
$persp: 300px;
$front: 120px; // the near end of the road, in front of the screen plane (< $persp)
$len: 1400px; // road length
$period: 100px; // one dash + one gap
$dash-time: 0.5s; // time to slide one period
$posts: 6; // per side
$post-time: $dash-time * math.div($len, $period); // same speed as the dashes
$step: math.div($post-time, $posts);
$sky-low: color-mix(in srgb, var(--hot) 50%, #2a0a3a);

.d-road {
  position: absolute;
  inset: 0;
  overflow: hidden;
  perspective: $persp;
  perspective-origin: 50% $horizon;
  background: linear-gradient(#07051a, color-mix(in srgb, var(--accent) 45%, #120733) 24%, $sky-low $horizon);

  // striped sunset sun; the ground (next sibling) covers its lower half
  &__sun {
    position: absolute;
    top: calc(#{$horizon} - 78px);
    left: calc(50% - 60px);
    width: 120px;
    height: 120px;
    border-radius: 50%;
    background: linear-gradient(var(--warm) 20%, var(--hot) 75%);
    box-shadow: 0 0 40px color-mix(in srgb, var(--hot) 60%, transparent);
    mask: linear-gradient(#000 30%, transparent 0 33%, #000 0 41%, transparent 0 45%, #000 0 52%, transparent 0 57%, #000 0);
  }

  // Flat ground with lines radiating from the vanishing point. They run parallel to the road,
  // so they would not move while driving straight: static is correct.
  &__ground {
    position: absolute;
    inset: $horizon 0 0;
    background:
      repeating-conic-gradient(from 90deg at 50% 0%, color-mix(in srgb, var(--hot) 50%, transparent) 0 0.5deg, transparent 0.5deg 9deg),
      linear-gradient(color-mix(in srgb, var(--accent) 40%, #1a0830), #06030f 70%);
  }

  &__plane {
    position: absolute;
    left: 29%;
    right: 29%;
    bottom: 0;
    height: $len;
    background: linear-gradient(
      90deg,
      var(--hot) 0 3px,
      color-mix(in srgb, var(--accent) 22%, #0a0616) 3px calc(100% - 3px),
      var(--hot) calc(100% - 3px)
    );
    transform-style: preserve-3d;
    transform-origin: bottom;
    transform: translateZ($front) rotateX(90deg);

    // Posts: stand up from the road (rotateX(-90deg) around their foot) and ride it from
    // the far end to the near end. The 0 → 1 opacity hides the pop-in in the haze.
    i {
      position: absolute;
      bottom: 0;
      width: 5px;
      height: 46px;
      border-radius: 3px;
      background: linear-gradient(var(--accent-2), color-mix(in srgb, var(--accent-2) 15%, transparent));
      box-shadow: 0 0 8px color-mix(in srgb, var(--accent-2) 70%, transparent);
      transform-origin: bottom;
      animation: d-road-post $post-time linear infinite;
      animation-delay: calc(var(--i) * #{-$step});

      &::before {
        content: '';
        position: absolute;
        top: -4px;
        left: -3px;
        width: 11px;
        height: 11px;
        border-radius: 50%;
        background: radial-gradient(circle, #fff 0 25%, var(--accent-2) 60%);
      }
    }

    .is-left {
      left: -22px;
    }

    // half a step later than the left row, so the two sides alternate
    .is-right {
      right: -22px;
      animation-delay: calc(var(--i) * #{-$step} - #{$step * 0.5});
    }
  }

  // the centre line: taller than the road by one period, so the slide never shows its end
  &__dashes {
    position: absolute;
    top: -$period;
    bottom: 0;
    left: calc(50% - 3px);
    width: 6px;
    background: repeating-linear-gradient(var(--warm) 0 50px, transparent 50px $period);
    animation: d-road-dash $dash-time linear infinite;
  }

  // A flat band of haze over the horizon hides where the road and posts vanish.
  // (A mask on the plane would flatten its 3D posts.)
  &__haze {
    position: absolute;
    inset: calc(#{$horizon} - 7%) 0 auto;
    height: 22%;
    background: linear-gradient(transparent, $sky-low 30% 50%, transparent);
    pointer-events: none;
  }
}

// translateZ(1px) keeps the paint just above the road surface (no z-fighting)
@keyframes d-road-dash {
  from {
    transform: translateZ(1px) translateY(0);
  }

  to {
    transform: translateZ(1px) translateY($period);
  }
}

@keyframes d-road-post {
  from {
    opacity: 0;
    transform: translateY(-$len) rotateX(-90deg);
  }

  12% {
    opacity: 1;
  }

  to {
    opacity: 1;
    transform: translateY(0) rotateX(-90deg);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.