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.

  • 118 lines of CSS
  • 9 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The system is a plane laid back with rotateX(56deg) 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(-56deg) 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.
  5. The ringed planet’s ring wraps it: the same tilted ellipse is drawn twice and cut along its long axis with clip-path. The far (upper) half is a ::before painted under the ball, an inner <i>, and the near half an ::after painted over it, simply in DOM order. The planet always faces you, so the upper half is the far one at every point of the orbit.
  6. Every length is a multiple of one base unit, --u, tied to the canvas: the widest orbit is 196 units across, and each ring’s radius and planet size are written on it as plain numbers that CSS multiplies by the unit. The plane leans back 56°, not further, so the system stands tall enough in the frame without growing wider than it.

Key techniques

  • rotateX tilted plane
  • rotateZ orbit per ring
  • counter-rotation = billboarding
  • negative animation-delay
  • ring split around the ball (clip-path)

Copy-paste code

HTML 9 lines

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

CSS 118 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the system is the same share of a
     gallery card, the editor, a full screen and a recording canvas */
  --u: 0.42vmin;
  display: grid;
  place-items: center;
  perspective: calc(800 * var(--u));
}

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

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

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

.orbit {
  position: absolute;
  inset: calc(50% - var(--r) * var(--u)); /* --r and --s are plain numbers, counted in units */
  border: calc(1 * var(--u)) 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: calc(-2 * var(--u));
  border-radius: 50%;
  background: conic-gradient(transparent 62%, var(--c));
  mask: radial-gradient(closest-side, transparent calc(100% - calc(3 * var(--u))), #000 calc(100% - calc(2 * var(--u))));
}

.orbit b {
  position: absolute;
  top: calc(var(--s) * var(--u) / -2);
  left: calc(50% - var(--s) * var(--u) / 2);
  width: calc(var(--s) * var(--u));
  height: calc(var(--s) * var(--u));
  /* same duration + delay as the ring: the two rotations cancel */
  animation: face var(--t) linear var(--d) infinite;
}

.orbit b,
.ringed i {
  border-radius: 50%;
  background: radial-gradient(circle at 34% 30%, #fff 0 6%, var(--c) 42%, color-mix(in srgb, var(--c) 45%, #000));
}

/* the ring wraps the ball: its far half (::before) is drawn under the ball (the <i>), its near
   half (::after) over it, in DOM order. The planet faces the camera, so the far half is always the
   upper half of the ellipse: both halves are the same tilted ellipse, centred on the ball and cut
   along its long axis, through the ball's centre */
.orbit .ringed {
  /* a box as wide as the ring, not just the ball: the planet cuts through the orbital plane, and
     where the browser splits it there, it dropped any ring that stuck out of the box */
  left: calc(50% - var(--s) * var(--u) * 1.1);
  width: calc(var(--s) * var(--u) * 2.2);
  background: none; /* the ball is the <i>, so it can sit between the two halves */
}

.ringed i {
  position: absolute;
  inset: 0 calc(var(--s) * var(--u) * 0.6);
}

.orbit .ringed::before,
.orbit .ringed::after {
  box-sizing: content-box; /* each ring is sized by its hole, its border drawn outside it */
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: calc(var(--s) * var(--u) * 1.8);
  height: calc(var(--s) * var(--u) / 2);
  border: calc(2 * var(--u)) solid #ffd79a;
  /* the same pale gold on a dark stage, a deeper gold on a light one: its lightness follows the
     stage's text colour, which the model inherits */
  border-color: oklch(from currentColor calc(0.75 + l * 0.16) calc(0.19 - l * 0.09) 75);
  border-radius: 50%;
  transform: translate(-50%, -50%) rotate(-18deg);
}

.orbit .ringed::before {
  clip-path: inset(0 0 50% 0);
}

.orbit .ringed::after {
  clip-path: inset(50% 0 0 0);
}

@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(-56deg) rotateZ(16deg); }
  to   { transform: rotateZ(-360deg) rotateX(-56deg) rotateZ(16deg); }
}
Standalone snippet · plain CSS, no build step, no dependencies.