Pure CSS Scenes & objects #loop #space #billboard #orbit

Solar system in pure CSS

Orbits are rings on a tilted plane, each spinning at its own speed. Every planet undoes both rotations, so it always faces the camera.

  • 81 lines of CSS
  • 9 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. The system is a plane laid back with rotateX(68deg) and turned a little on screen with rotateZ(-16deg). Every orbit is a ring on that plane.
  2. Each ring spins with rotateZ(0 → 360deg); its planet sits on the ring’s top edge and rides along. A negative animation-delay starts every planet at a different angle.
  3. To keep a planet round and facing you, undo everything above it in reverse order: rotateZ(-angle) rotateX(-68deg) rotateZ(16deg). The planet runs that with the same duration and delay as its ring, so the rotations cancel at every frame. That is billboarding.
  4. The sun gets the same inverse without the spin. The fading trail is a conic-gradient cut to a thin ring with a mask, on a pseudo-element, so the mask never flattens the planet.

Key techniques

  • rotateX tilted plane
  • rotateZ orbit per ring
  • counter-rotation = billboarding
  • negative animation-delay

Copy-paste code

HTML 9 lines

<div class="scene">
  <div class="system">
    <div class="sun"></div>
    <div class="orbit" style="--r:34px;--t:5s;--s:9px;--c:#2ee6d6;--d:-1s"><b></b></div>
    <div class="orbit" style="--r:54px;--t:9s;--s:13px;--c:#ff4d9d;--d:-6s"><b></b></div>
    <div class="orbit" style="--r:76px;--t:14s;--s:11px;--c:#8b6cff;--d:-3s"><b></b></div>
    <div class="orbit" style="--r:98px;--t:22s;--s:16px;--c:#ffb547;--d:-15s"><b class="ringed"></b></div>
  </div>
</div>

CSS 81 lines

.scene {
  perspective: 800px;
}

.system,
.system * {
  box-sizing: border-box;
}

/* the orbital plane: rolled on screen, then laid back */
.system {
  position: relative;
  width: 200px;
  height: 200px;
  transform-style: preserve-3d;
  transform: rotateZ(-16deg) rotateX(68deg);
}

.sun {
  position: absolute;
  inset: calc(50% - 13px);
  border-radius: 50%;
  background: radial-gradient(circle at 38% 34%, #fff8e1, #ffb547 45%, #ff4d9d);
  box-shadow: 0 0 16px #ffb547, 0 0 40px rgb(255 77 157 / 0.45);
  transform: rotateX(-68deg) rotateZ(16deg); /* the plane, undone: faces the camera */
}

.orbit {
  position: absolute;
  inset: calc(50% - var(--r));
  border: 1px solid color-mix(in srgb, var(--c) 30%, transparent);
  border-radius: 50%;
  transform-style: preserve-3d;
  animation: orbit var(--t) linear var(--d) infinite;
}

/* a fading trail behind the planet: a conic gradient masked down to a thin ring */
.orbit::before {
  content: '';
  position: absolute;
  inset: -2px;
  border-radius: 50%;
  background: conic-gradient(transparent 62%, var(--c));
  mask: radial-gradient(closest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
}

.orbit b {
  position: absolute;
  top: calc(var(--s) / -2);
  left: calc(50% - var(--s) / 2);
  width: var(--s);
  height: var(--s);
  border-radius: 50%;
  background: radial-gradient(circle at 34% 30%, #fff 0 6%, var(--c) 42%, color-mix(in srgb, var(--c) 45%, #000));
  /* same duration + delay as the ring: the two rotations cancel */
  animation: face var(--t) linear var(--d) infinite;
}

/* a flat ring; hiding its top border makes the far half pass "behind" */
.orbit .ringed::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 180%;
  height: 50%;
  border: 2px solid #ffd79a;
  border-top-color: transparent;
  border-radius: 50%;
  transform: translate(-50%, -50%) rotate(-18deg);
}

@keyframes orbit {
  to { transform: rotateZ(360deg); }
}

/* undo the ring's spin, then the plane's tilt, then its roll */
@keyframes face {
  from { transform: rotateZ(0deg) rotateX(-68deg) rotateZ(16deg); }
  to   { transform: rotateZ(-360deg) rotateX(-68deg) rotateZ(16deg); }
}

Sass source 117 lines

// The plane is laid back with rotateX and turned a little on screen with rotateZ.
// Each orbit ring spins on Z inside that plane; its planet undoes the spin AND the plane's
// tilt, in reverse order, so it always faces the camera (billboarding).
$tilt: 68deg;
$roll: -16deg;
$face: rotateX(-$tilt) rotateZ(-$roll); // the inverse of the plane, applied last

.d-solar {
  position: relative;
  width: 200px;
  height: 200px;
  transform-style: preserve-3d;
  transform: rotateZ($roll) rotateX($tilt);

  &__sun {
    position: absolute;
    inset: calc(50% - 13px);
    border-radius: 50%;
    background: radial-gradient(circle at 38% 34%, #fff8e1, var(--warm) 45%, var(--hot) 100%);
    box-shadow:
      0 0 16px color-mix(in srgb, var(--warm) 80%, transparent),
      0 0 40px color-mix(in srgb, var(--hot) 45%, transparent);
    transform: $face;

    // a soft corona that breathes (opacity + scale only)
    &::after {
      content: '';
      position: absolute;
      inset: -9px;
      border-radius: 50%;
      background: radial-gradient(circle, color-mix(in srgb, var(--warm) 55%, transparent) 40%, transparent 70%);
      animation: d-solar-glow 3s ease-in-out infinite alternate;
    }
  }

  &__orbit {
    position: absolute;
    inset: calc(50% - var(--r));
    border: 1px solid color-mix(in srgb, var(--c) 30%, transparent);
    border-radius: 50%;
    transform-style: preserve-3d;
    animation: d-solar-orbit var(--t) linear var(--d) infinite;

    // A fading trail on the ring behind the planet: a conic gradient cut to a thin ring.
    // It has no children, so the mask cannot flatten anything.
    &::before {
      content: '';
      position: absolute;
      inset: -2px;
      border-radius: 50%;
      background: conic-gradient(transparent 62%, color-mix(in srgb, var(--c) 85%, transparent));
      mask: radial-gradient(closest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
    }

    b {
      position: absolute;
      top: calc(var(--s) / -2);
      left: calc(50% - var(--s) / 2);
      width: var(--s);
      height: var(--s);
      border-radius: 50%;
      background: radial-gradient(
        circle at 34% 30%,
        #fff 0 6%,
        var(--c) 42%,
        color-mix(in srgb, var(--c) 45%, #000) 100%
      );
      box-shadow: 0 0 10px color-mix(in srgb, var(--c) 55%, transparent);
      // same duration and delay as the ring, so the two rotations cancel at every moment
      animation: d-solar-face var(--t) linear var(--d) infinite;
    }

    // Saturn-ish: the ring is 2D (the planet always faces us). Hiding the top border
    // makes the ring's far half look like it passes behind the planet.
    .is-ringed::after {
      content: '';
      position: absolute;
      top: 50%;
      left: 50%;
      width: 180%;
      height: 50%;
      border: 2px solid color-mix(in srgb, var(--c) 80%, #fff);
      border-top-color: transparent;
      border-radius: 50%;
      transform: translate(-50%, -50%) rotate(-18deg);
    }
  }
}

@keyframes d-solar-orbit {
  to {
    transform: rotateZ(360deg);
  }
}

// rotateZ(-angle) undoes the ring; then the plane's tilt and roll are undone
@keyframes d-solar-face {
  from {
    transform: rotateZ(0deg) $face;
  }

  to {
    transform: rotateZ(-360deg) $face;
  }
}

@keyframes d-solar-glow {
  from {
    opacity: 0.55;
    transform: scale(0.92);
  }

  to {
    opacity: 1;
    transform: scale(1.12);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.