Aduok Code

A Solar System Explorer in Pure CSS, Built from Radio-Button State, a Registered Animated Variable, a 3D Fly-Through Scene, and Moons Traced by Two Perpendicular Sweeps

Introduction

This solar system explorer has no JavaScript file, no image files, and no animation library. A list of nine worlds sits on the left of a dark starfield. Pick one and the camera flies forward through space until that planet fills the middle of the screen, its moons start to circle it, and its name, its distance from the Sun, and an Explore button appear beneath it. Press Explore and a frosted drawer slides in with its size, its year, its day, its moon count, and three facts.

The page rests on four ideas: radio inputs that remember which planet and which drawer are open, one registered CSS number that animates smoothly between planets, a 3D scene where every planet sits on a line stretching away from the camera, and a handful of gradients that paint each world without a single image. Everything else, from the fade of the text to the pause of the moons, is calculated from those four.

What you will learn

How to keep page state in radio inputs and read it with :has() — how to register a custom property so it can be transitioned — how to build a camera fly-through with perspective and translate — how to derive fades and scales from one number with calc() — how to paint a spinning, lit planet with gradients — how to make a moon pass in front of and behind its planet — and how to draw hundreds of moons without slowing the page.

01
Part One
State Held by Radio Buttons

Step 1 — A Radio Input Remembers the Choice

A radio group can only have one checked member, which makes it a free state machine. The planet list is nine radio inputs that share the name planet, each followed by a label that is styled as a row with a small sphere, a name, and a distance. Clicking a label checks its radio. The inputs are visually hidden but stay focusable, so the arrow keys move between planets when the list has focus.

HTML
<nav class="nav" aria-label="Choose a planet">
  <input class="nav__input" type="radio" name="planet" id="mercury" checked>
  <label class="nav__item theme-mercury" for="mercury">
    <span class="nav__orb" aria-hidden="true"></span>
    <span class="nav__text">
      <span class="nav__name">Mercury</span>
      <span class="nav__meta">0.39 AU</span>
    </span>
  </label>

  <input class="nav__input" type="radio" name="planet" id="venus">
  <label class="nav__item theme-venus" for="venus"> ... </label>
  <!-- earth, mars, jupiter, saturn, uranus, neptune, pluto -->
</nav>

The radios are inside the nav, but the planets they control are somewhere else on the page. A sibling selector cannot reach that far, so the page uses :has() on the outer wrapper. The rule asks whether the wrapper contains a checked radio with a given id, and if so it writes two custom properties: the index of that planet and its colour.

CSS
.app:has(#mercury:checked) { --active: 0; --accent: var(--mercury); }
.app:has(#venus:checked)   { --active: 1; --accent: var(--venus); }
.app:has(#earth:checked)   { --active: 2; --accent: var(--earth); }
.app:has(#mars:checked)    { --active: 3; --accent: var(--mars); }
.app:has(#jupiter:checked) { --active: 4; --accent: var(--jupiter); }
.app:has(#saturn:checked)  { --active: 5; --accent: var(--saturn); }
.app:has(#uranus:checked)  { --active: 6; --accent: var(--uranus); }
.app:has(#neptune:checked) { --active: 7; --accent: var(--neptune); }
.app:has(#pluto:checked)   { --active: 8; --accent: var(--pluto); }

These nine lines are the entire wiring. No other rule needs to know which planet is selected, because every descendant can read --active and --accent from the wrapper. The details drawers work the same way with a second radio group named panel: each drawer contains its own hidden radio, and one extra radio called panel-none, checked by default, is the closed state.

The HTML says which planet is chosen. One number says where the camera is. The CSS does the rest.

02
Part Two
One Animated Number Drives the Camera

Step 2 — Registering --active So It Can Transition

A custom property is just text until the browser is told its type, so by default it changes instantly. Registering it with @property says that --active is a number and --accent is a colour. Once the browser knows that, it can interpolate them, and a normal transition on the wrapper makes the selected index glide from 2 to 4 through 2.5 and 3.1 instead of jumping.

CSS
@property --active {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

@property --accent {
  syntax: "<color>";
  inherits: true;
  initial-value: #d6a58a;
}

.app {
  --active: 0;
  --accent: var(--mercury);

  transition:
    --active 1.4s var(--ease-move),
    --accent 1s ease;
}

This is the trick that makes the whole page smooth. During the 1.4 seconds of the transition, --active holds a fractional value on every frame, and every element that uses it in a calc() follows along. There is no keyframe for each planet and no delay chain. The same number also shifts the three star layers by different amounts for parallax, and --accent tints a soft nebula behind the scene with color-mix, so the glow slowly changes colour as you travel.

Why not just set transform on the planet?

A transition on each planet would need its own timing, and moving the selection halfway through a trip would restart it. A single animated number is always in the middle of one trip. Change the target and it simply turns toward the new value, and every planet stays in step.

03
Part Three
Planets on a Line in 3D

Step 3 — A Line That Recedes Into the Screen

The stage is the camera. It sets a perspective of 900 pixels with the vanishing point a little above the middle of the screen, and its child, the track, uses transform-style: preserve-3d so its children live in the same 3D space. Every planet is placed on one line pointing away from the viewer, with the next planet behind the current one and the one after that behind it.

CSS
.stage {
  position: absolute;
  inset: 0;
  perspective: var(--perspective);          /* 900px */
  perspective-origin: 50% 38%;
}

.track {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
}

.slot {
  /* Signed distance ahead of the selected planet; 0 means in focus */
  --rel: calc(var(--i) - var(--active));
  /* Absolute distance */
  --d: max(var(--rel), calc(var(--rel) * -1));

  display: contents;
}

Each section in the HTML carries its position in the line as an inline custom property, for example style="--i: 4" for Jupiter. The slot subtracts the animated --active from it to get --rel, the number of steps the planet is ahead of the camera. A slot uses display: contents so it draws nothing itself. Its two children, the planet and the text block, are placed straight into the 3D track, while the slot only hands them its variables.

CSS
.planet {
  --size: calc(var(--base) * var(--scale));

  position: absolute;
  top: var(--planet-y);
  left: 50%;
  width: var(--size);
  aspect-ratio: 1;
  margin: calc(var(--size) / -2) 0 0 calc(var(--size) / -2);

  translate:
    0
    calc(var(--rel) * var(--lift) * -1)     /* 55dvh */
    calc(var(--rel) * var(--depth) * -1);   /* 1800px */
  opacity: min(calc(1 + var(--rel) * 2.5), calc(1 - var(--rel) * 0.6));
}

The translate has three values. A planet that is one step ahead is pushed 1800 pixels away from the camera, so perspective shrinks it, and lifted by 55 percent of the viewport height, so it floats above the one in front like the next stop on a road. The planet in focus has --rel of zero and sits at the origin. As --active rises, the planet that was in focus gets a negative --rel and flies toward the camera, growing as it goes, and the next one slides down into the middle.

The opacity line is a min of two straight lines. For planets ahead, the second line drops by 0.6 per step, so the next one is dim and the one after it is invisible. For planets behind the camera, the first line drops by 2.5 per step, so a planet fades out almost as soon as it starts flying past. The result is that at most two planets are visible at once, and none of them ever pops.

CSS
.slot__info {
  position: absolute;
  left: 50%;
  bottom: var(--info-bottom);
  opacity: calc(1 - var(--d) * 1.6);
  translate: -50% calc(min(var(--d), 1) * 1.25rem);
}

.cta {
  /* Collapses to nothing on non-active slots so it cannot be clicked */
  scale: max(0, calc(1 - var(--d) * 2));
}

The text blocks of all nine planets sit in the same place, stacked. Only the one with --d near zero is visible, and the others are transparent. A transparent button would still catch clicks, so the Explore button also scales to zero as the distance grows. An element with a scale of zero has no size to click, so only the button for the planet in focus can ever be pressed.

04
Part Four
Painting a Planet with Gradients

Step 4 — A Texture, a Spin, and a Light

Every planet is a circle with a gradient texture stored in a custom property called --surface, defined once inside a theme class such as .theme-jupiter. The same property paints the sphere in the list on the left, the planet on the stage, and the small orb at the top of the drawer, so the three can never drift apart. Mercury is a grey linear gradient with dark radial gradients for craters, Earth stacks green ellipses over blue for continents, and Jupiter is a long list of colour stops for its bands with one red ellipse for the Great Red Spot.

CSS
.theme-mercury {
  --hue:  var(--mercury);
  --glow: rgb(214 165 138 / 0.4);
  --rim:  rgb(255 225 205 / 0.3);
  --surface:
    radial-gradient(circle at 18% 32%, rgb(0 0 0 / 0.28) 0 5%,
                    rgb(255 255 255 / 0.12) 5.6% 6.4%, transparent 7%),
    radial-gradient(circle at 60% 58%, rgb(0 0 0 / 0.26) 0 8%, transparent 10%),
    linear-gradient(120deg, #a89a90, #766b64 55%, #8f837a);
}

The spin is a scrolling background. The body has a pseudo-element twice as wide as the planet, and the texture is tiled at exactly half of that width, so one tile equals the planet width. Sliding the pseudo-element left by 50 percent moves it by exactly one tile, which looks identical to the start, so the animation can loop without a visible seam. The circle clips it, and each planet has its own speed in --spin, from 60 seconds for Jupiter to 220 for Venus.

CSS
.planet__body {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: 0 0 calc(var(--size) * 0.35) calc(var(--size) * -0.08) var(--glow);
}

.planet__body::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 200%;
  background: var(--surface);
  background-size: 50% 100%;
  background-repeat: repeat-x;
  animation: planet-spin var(--spin, 120s) linear infinite;
}

.planet__body::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: radial-gradient(circle at 30% 28%,
    transparent 0 26%, rgb(2 3 12 / 0.55) 66%, rgb(2 3 12 / 0.96) 100%);
  box-shadow: inset 0 0 calc(var(--size) * 0.06) var(--rim);
}

@keyframes planet-spin {
  to { transform: translateX(-50%); }
}

A flat texture does not look round until it is lit. The second pseudo-element lays a radial gradient over everything that is clear near the top left and nearly black at the bottom right, which turns the circle into a sphere with a sun on one side. An inset shadow in the planet colour adds a thin atmosphere along the edge, and the outer box-shadow, with a negative spread, makes a glow that stays close to the planet instead of becoming a flat halo.

CSS
.planet__ring {
  position: absolute;
  top: 50%;
  left: 50%;
  width: calc(100% * var(--ring-scale, 2));
  aspect-ratio: 1;
  border-radius: 50%;
  translate: -50% -50%;
  rotate: var(--ring-tilt, -16deg);
  transform: perspective(900px) rotateX(72deg);
  background: var(--ring);
}

.planet__ring--back  { z-index: -1; clip-path: inset(0 0 50% 0); }
.planet__ring--front { z-index: 1;  clip-path: inset(50% 0 0 0); }

Saturn and Uranus add one ring, drawn as a radial gradient on a circle that is tipped back with rotateX and turned in its plane with rotate. To make the ring pass behind the planet at the top and in front of it at the bottom, the markup contains the ring twice, once before the body and once after it. One copy is clipped to its upper half with a z-index of minus one, the other to its lower half with a z-index of one, and together they read as one ring wrapped around the sphere. Uranus uses the same pieces with a tilt of minus 78 degrees, which makes its rings stand almost upright.

05
Part Five
Moons, Front and Back

Step 5 — Two Sweeps Make an Ellipse

A circular orbit seen from a slight angle is an ellipse, and an ellipse is the sum of two back-and-forth motions at right angles, a quarter of a cycle apart. So a moon is two nested elements. The outer one slides left and right, and the inner one slides up and down. Each uses an eased alternate animation that runs for half the orbit period, and the inner one starts a quarter of a period later.

HTML
<span class="moon" style="--orbit: .85; --moon: .2; --period: 16s; --phase: 0s; --tint: #cfcfd1">
  <span class="moon__y">
    <span class="moon__body"></span>
    <span class="moon__label">Moon</span>
  </span>
</span>
CSS
.moon {
  --m:  max(8px, calc(var(--size) * var(--moon)));
  --rx: calc(var(--size) * var(--orbit));
  --ry: calc(var(--rx) * var(--flat, 0.2));

  position: absolute;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;

  animation:
    moon-sweep-x calc(var(--period) / 2) ease-in-out infinite alternate,
    moon-depth var(--period) linear infinite;
  animation-delay: var(--phase), var(--phase);
  animation-play-state: var(--moon-play, paused);
}

.moon__y {
  position: absolute;
  width: 0;
  height: 0;
  animation: moon-sweep-y calc(var(--period) / 2) ease-in-out infinite alternate;
  animation-delay: calc(var(--phase) - var(--period) / 4);
  animation-play-state: var(--moon-play, paused);
}

@keyframes moon-sweep-x {
  from { translate: calc(var(--rx) * -1) 0; }
  to   { translate: var(--rx) 0; }
}

@keyframes moon-sweep-y {
  from { translate: 0 calc(var(--ry) * -1); }
  to   { translate: 0 var(--ry); }
}

/* First half of the orbit passes in front of the planet, second half behind */
@keyframes moon-depth {
  0%, 49.99% { z-index: 2; }
  50%, 100%  { z-index: -1; }
}

Both radii are written in planet sizes, so a moon with --orbit of 0.85 circles at 85 percent of the planet width no matter how large the planet is drawn. The vertical radius is a fraction of the horizontal one, set with --flat, so a flatter value looks like a more tilted orbit. A negative --phase starts each moon part-way around, so a family of moons does not begin in a line.

The front and back is done by animating z-index, which is allowed because it changes in steps. For the first half of every period the moon has a z-index of 2 and passes in front of the planet, and for the second half it has minus one and passes behind. The planet makes its own stacking context, so the minus one only reaches behind the planet body and never behind the page. The half-way point lines up with the moment the vertical sweep crosses zero, which is why the moon is always in front on the lower half of its path.

CSS
.moon--minor {
  --m: max(3px, calc(var(--size) * var(--moon)));
}

.moon--minor::before {
  content: "";
  position: absolute;
  width: var(--m);
  aspect-ratio: 1;
  margin: calc(var(--m) / -2);
  border-radius: 50%;
  background: radial-gradient(circle at 32% 30%,
    rgb(255 255 255 / 0.7), var(--tint, #b9b2a8) 60%);
  animation: moon-sweep-y calc(var(--period) / 2) ease-in-out infinite alternate;
  animation-delay: calc(var(--phase) - var(--period) / 4);
  animation-play-state: var(--moon-play, paused);
}

/* Only the planet in focus animates its moons */
.app:has(#jupiter:checked) .theme-jupiter,
.app:has(#saturn:checked)  .theme-saturn,
.app:has(#neptune:checked) .theme-neptune {
  --moon-play: running;
}

Jupiter has 115 known moons and Saturn has 293, and the page draws every one. The biggest few are named and labelled using the three-layer markup above. The rest are minor moons that use a single element: the element does the horizontal sweep, and its ::before pseudo-element is the sphere and does the vertical sweep. That halves the number of elements, and the rest of the cost is removed by pausing. The moons are paused by default, and a rule that checks the selected planet sets --moon-play to running only on its slot, so just one planet worth of animations runs at any time.

Why the moon count is exact

The number shown in each drawer and the number of moon elements in that planet are the same figure, and the page was built by generating the markup from a list, then checking the count of drawn moons against the count in the stats. The small ones are placed with random orbits, so their positions are illustrative, but the total is real.

06
Part Six
The Drawer, the List, and Reduced Motion

Step 6 — Sliding Panels Without a Script

The Explore button is a label for the hidden radio inside the matching drawer, and the Close button and the blurred backdrop are labels for the closed radio. The drawer asks whether it contains its own checked radio and, if so, slides in. The backdrop does the same through the wrapper. Because there is only one radio group for drawers, opening one automatically closes any other.

CSS
.panel {
  position: fixed;
  inset: 0 0 0 auto;
  z-index: 20;
  width: min(30rem, 100%);
  overflow-y: auto;
  translate: 100% 0;
  visibility: hidden;
  transition: translate 0.6s var(--ease-out), visibility 0s 0.6s;
}

.panel:has(.panel__toggle:checked) {
  translate: 0 0;
  visibility: visible;
  transition: translate 0.6s var(--ease-out), visibility 0s;
}

.app:has(.panel__toggle:checked) .backdrop {
  opacity: 1;
  pointer-events: auto;
}

Closed drawers sit off the right edge, but sliding alone would leave their links reachable with the Tab key. Setting visibility to hidden removes them from the tab order and from screen readers. Visibility cannot be faded, so it is given a delay equal to the slide: when closing, it waits 0.6 seconds and flips after the drawer has left, and when opening, the delay is zero so the drawer is visible from the first frame. The drawer background is mixed from the planet colour with color-mix, so each one takes on a hint of its planet.

On narrow screens the planet list leaves the left edge and becomes a strip along the bottom that scrolls sideways, the distance labels are hidden, and the text block is lifted to sit above it. The size of a planet comes from the smaller of 44 percent of the viewport height and 58 percent of its width, so planets fit on both a short laptop and a tall phone. A short-screen media query shrinks them a little more.

CSS
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    transition-delay: 0s !important;
  }
}

For anyone who asks their system for less motion, the transitions become instant and the animations run once, so the camera cuts straight to the chosen planet, no moon circles, and no texture spins. The page still changes planet, still opens the drawer, and every figure is still there. Only the movement is gone.

What pure CSS cannot do here

The planet list works with the arrow keys because radios do. The Explore and Close controls are labels, which are not focusable and do not react to Enter, so they need a click or a tap. If keyboard access to the drawer matters for your project, add a short script that opens it on Enter and closes it on Escape.

Tuning Reference

PropertyExampleEffect
--perspective900pxCamera distance; lower values exaggerate the depth
--depth1800pxGap between planets along the view axis; larger makes the next planet smaller
--lift55dvhHow far each farther planet rises above the one in front
--basemin(44dvh, 58vw)Diameter of a planet with a scale of 1
--planet-y40%Height of the planet centre on the stage
--active transition1.4s ease-in-outLength and feel of the fly-through
--scale (planet).42 to 1Relative size of each world; Jupiter is 1
--spin (planet)60s to 220sTime for one turn of the texture
--ring-scale / --ring-tilt2 / -16degRing width in planet widths and its angle
--orbit / --flat (moon).85 / .2Orbit width in planet sizes and how flat the ellipse is
--period / --phase (moon)16s / -4sTime for one orbit and where in it the moon begins

Full Source Code

The whole project is a single self-contained HTML file — markup and styles, with no script and no build step. The only external request is the Google Fonts stylesheet for the two typefaces. It needs a browser with :has() and @property, which means a current Chrome, Edge, Safari, or Firefox.

HTML — solar-system-explorer.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/solar-system-explorer.html
Want to try it yourself?Open the live solar-system-explorer.html file