A florist sells four bouquet sizes, and the order page shows them as four plain radio circles in a row. They work, but nothing on the page says "this is the nice part where you choose something lovely". Orbit radio buttons turn each size into a small planet, scaled to match the bouquet, and a gold moon circles whichever one is picked. Choose another size and the moon flies over in an arc and settles into a new orbit. Underneath, it is still an ordinary radio group.
Classic bouquet, 14 stems, $55.
How it works
- Real radios first. Each option is a
labelwrapping a nativeinput type="radio". The input is stretched over the whole option and made invisible, so clicks, taps, arrow keys and screen readers all behave exactly as they would on a plain form. - Planets are just circles. Each planet is a
spanwith a radial gradient. Its size comes from a--scustom property in the markup, so the Grand bouquet looks bigger than the Petite one at a glance. - The moon follows a squashed circle. Every animation frame the script adds a little to an angle and places the moon with cosine for the horizontal position and a flattened sine for the vertical one. That flattening is what makes the orbit look tilted.
- Behind, then in front. On the far half of the orbit the moon drops to a lower
z-indexthan the planet, and on the near half it rises above it. That one swap gives the effect its depth. - Flying between choices. When the selection changes, the orbit centre eases from the old planet to the new one over 650 milliseconds and lifts into an arc on the way, while the moon keeps circling.
- It knows when to stop. Six seconds after the last change, the moon parks at the front of its planet and the loop ends. It also pauses while the demo is scrolled out of view.
The code
Three self contained blocks; the markup carries the options, prices and stem counts, so you rarely need to touch the script.
<div class="orb" data-lab="orbit-radio">
<fieldset class="orb-set">
<legend class="orb-legend">Pick a bouquet size</legend>
<div class="orb-track">
<label class="orb-opt">
<input class="orb-input" type="radio" name="orb-size" value="Petite" data-price="$35" data-stems="8 stems">
<span class="orb-planet" style="--s: 38px" aria-hidden="true"></span>
<span class="orb-name">Petite</span>
<span class="orb-price">$35</span>
</label>
<label class="orb-opt">
<input class="orb-input" type="radio" name="orb-size" value="Classic" data-price="$55" data-stems="14 stems" checked>
<span class="orb-planet" style="--s: 48px" aria-hidden="true"></span>
<span class="orb-name">Classic</span>
<span class="orb-price">$55</span>
</label>
<label class="orb-opt">
<input class="orb-input" type="radio" name="orb-size" value="Generous" data-price="$80" data-stems="22 stems">
<span class="orb-planet" style="--s: 58px" aria-hidden="true"></span>
<span class="orb-name">Generous</span>
<span class="orb-price">$80</span>
</label>
<label class="orb-opt">
<input class="orb-input" type="radio" name="orb-size" value="Grand" data-price="$120" data-stems="35 stems">
<span class="orb-planet" style="--s: 68px" aria-hidden="true"></span>
<span class="orb-name">Grand</span>
<span class="orb-price">$120</span>
</label>
<span class="orb-moon" aria-hidden="true"></span>
</div>
</fieldset>
<p class="orb-note">Classic bouquet, 14 stems, $55.</p>
</div>.orb {
width: 100%;
max-width: 460px;
margin: 0 auto;
font-family: system-ui, sans-serif;
color: #101a14;
}
.orb-set { margin: 0; padding: 0; border: 0; min-width: 0; }
.orb-legend {
display: block;
width: 100%;
margin: 0 0 6px;
padding: 0;
font: 700 17px/1.3 system-ui, sans-serif;
text-align: center;
color: #133621;
}
/* The track is its own stacking context, so the moon can pass behind a planet. */
.orb-track {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.orb-opt {
position: relative;
display: grid;
grid-template-rows: 88px auto auto;
justify-items: center;
align-items: center;
margin: 0;
padding: 0 0 8px;
border-radius: 14px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* The real radio covers the whole option, invisible, so taps and keys stay native. */
.orb-input {
position: absolute;
inset: 0;
z-index: 3;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.orb-planet {
position: relative;
z-index: 1;
width: calc(var(--s) * var(--k, 1));
height: calc(var(--s) * var(--k, 1));
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #4d8a62, #133621 70%);
box-shadow: inset -4px -6px 10px rgba(16, 26, 20, .35);
transition: box-shadow .3s, transform .3s;
}
.orb-input:checked ~ .orb-planet {
box-shadow: inset -4px -6px 10px rgba(16, 26, 20, .35), 0 0 0 3px #f2f3ef, 0 0 0 5px #f4d96c;
transform: scale(1.04);
}
.orb-input:focus-visible ~ .orb-planet { outline: 3px solid #133621; outline-offset: 7px; }
.orb-opt:hover .orb-planet { transform: scale(1.06); }
.orb-name { font-size: 15px; font-weight: 600; }
.orb-price { font-size: 14px; color: #46534a; }
.orb-input:checked ~ .orb-name { color: #133621; }
/* The gold moon. JavaScript moves it with a transform and swaps its z-index. */
.orb-moon {
position: absolute;
left: -7px;
top: -7px;
z-index: 2;
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff6cf, #f4d96c 60%, #c9a93a);
box-shadow: 0 0 10px rgba(244, 217, 108, .8);
pointer-events: none;
}
.orb-note {
min-height: 1.4em;
margin: 10px 0 0;
font-size: 15px;
text-align: center;
color: #46534a;
}
@media (max-width: 480px) {
.orb-track { --k: .8; gap: 2px; }
.orb-opt { grid-template-rows: 74px auto auto; }
.orb-name { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.orb-planet { transition: none; }
}(function () {
var root = document.querySelector('[data-lab="orbit-radio"]');
if (!root) return;
var track = root.querySelector('.orb-track');
var moon = root.querySelector('.orb-moon');
var note = root.querySelector('.orb-note');
var inputs = root.querySelectorAll('.orb-input');
var still = window.matchMedia('(prefers-reduced-motion: reduce)');
var SPIN = 0.0036; // radians per millisecond, about 1.7 seconds per lap
var TILT = 0.34; // squashes the circle into a tilted orbit
var TRAVEL = 650; // milliseconds to fly to a new choice
var REST = 6000; // after this long without a change, the moon parks
var TWO_PI = Math.PI * 2, FRONT = Math.PI / 2;
var from, to, cur, angle = FRONT, flightStart = 0, changed = 0;
var raf = 0, last = 0, visible = true;
// Centre and orbit radius of the planet that belongs to a radio.
function orbitOf(input) {
var a = input.parentNode.querySelector('.orb-planet').getBoundingClientRect();
var b = track.getBoundingClientRect();
return { x: a.left - b.left + a.width / 2, y: a.top - b.top + a.height / 2, r: a.width / 2 + 11 };
}
function checked() {
for (var i = 0; i < inputs.length; i++) if (inputs[i].checked) return inputs[i];
return inputs[0];
}
function draw(c, front) {
var x = c.x + Math.cos(angle) * c.r;
var y = c.y + Math.sin(angle) * c.r * TILT;
moon.style.transform = 'translate(' + x + 'px,' + y + 'px)';
// Behind the planet on the far half of the orbit, in front on the near half.
moon.style.zIndex = front ? 2 : 0;
}
function frame(t) {
var dt = last ? Math.min(50, t - last) : 16;
var before = angle;
last = t;
angle += SPIN * dt;
// Ease the centre from the old planet to the new one, lifted into an arc.
var k = Math.min(1, (t - flightStart) / TRAVEL);
var e = k < 0.5 ? 2 * k * k : 1 - Math.pow(2 - 2 * k, 2) / 2;
var lift = Math.sin(Math.PI * e) * Math.min(40, Math.abs(to.x - from.x) * 0.3);
cur = { x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e - lift, r: from.r + (to.r - from.r) * e };
// Once things are quiet, stop when the moon next reaches the front.
var parking = k === 1 && t - changed > REST;
if (parking && Math.floor((before - FRONT) / TWO_PI) !== Math.floor((angle - FRONT) / TWO_PI)) {
angle = FRONT;
draw(cur, true);
raf = 0;
return;
}
draw(cur, k < 1 || Math.sin(angle) > 0);
raf = visible ? requestAnimationFrame(frame) : 0;
}
function run() {
if (raf || !visible || still.matches) return;
last = 0;
raf = requestAnimationFrame(frame);
}
function choose(input, fly) {
var target = orbitOf(input);
from = fly && cur && !still.matches ? cur : target;
to = target;
flightStart = changed = performance.now();
note.textContent = input.value + ' bouquet, ' + input.getAttribute('data-stems') + ', ' + input.getAttribute('data-price') + '.';
if (still.matches) {
// Reduced motion: no orbit, the moon simply sits in front of the choice.
cur = target;
angle = FRONT;
draw(cur, true);
return;
}
run();
}
for (var i = 0; i < inputs.length; i++) {
inputs[i].addEventListener('change', function (e) { choose(e.target, true); });
}
// Layout changes move the planets, so snap the moon to the new spot.
window.addEventListener('resize', function () {
cur = null;
choose(checked(), false);
});
// Pause the loop while the demo is off screen.
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) run();
}).observe(root);
}
choose(checked(), false);
})();When to use it, and when not to
It suits a single, enjoyable choice with three to five options that differ in size or amount: bouquet sizes, cake tiers, pizza sizes, gift box options, or a small, medium and large session at a photo studio. The planet sizes do real work there, because the difference between options is visible before anyone reads a price.
It is a poor fit for long lists, for options that have no natural order, or for dull admin forms like a billing address, where playful motion only slows people down. Keep it to one group per page; three orbiting moons on one form would compete for attention.
On accessibility, the hard parts are handled by the browser because the radios are real. Keyboard users see a clear outline around the focused planet, and screen readers announce the legend, the option name and the price. The moon is marked aria-hidden, since it repeats what the checked state already says. With reduced motion switched on in the operating system, the moon simply sits in front of the chosen planet and nothing moves.
Make it yours
- Speed and feel: change
SPINfor how fast the moon circles,TRAVELfor the flight time between choices andTILTfor how flat the orbit looks (0.2 is very flat, 0.6 is almost a circle). - Options: edit the labels in the HTML. Each input's
value,data-priceanddata-stemsfeed the summary line, and the--svalue on each planet sets its size. - How long it keeps moving: raise or lower
REST. Set it very high if you want the moon to circle for as long as the demo is on screen.
Frequently asked questions
Will it still submit with my form?
Yes. The inputs are normal radios with a shared name, so the chosen value is sent with the form like any other field. Change the name to whatever your order system expects.
Does it work on phones?
Yes. The whole option is a tap target, the planets shrink a little on narrow screens so four fit in a row, and the moon is drawn with a CSS transform, which phones handle smoothly.
Does the animation drain the battery?
Very little. It runs only after a change, pauses when the demo is off screen and stops completely a few seconds after the last choice, so there is no loop running while someone reads the rest of the page.