Solar system
Pure CSSOrbits are rings on a tilted plane, each spinning at its own speed. Every planet undoes both rotations, so it always faces the camera.
Click anywhere on the canvas and the confetti bursts from that very point. JS gives each piece a random 3D vector in per cent of the canvas, and one CSS animation flies them all out to its edges.
Your edited version
--x, --y (per cent of the canvas from its middle), --z, --spin, --hue. The vectors come in mirrored pairs, one to the left and one to the right, so the burst is always balanced, and they reach the canvas edges.--z is what makes it 3D: particles coming toward the camera grow, the others shrink away.cancel() then play(). Clicking as fast as a finger can is then only new values on elements that are already there — nothing is made or unmade while the page is drawing, nothing waits on animationend, and a piece a stage froze in mid-air is simply thrown afresh. A stage that holds its animations — the Pause switch, the export dialog's held pose, a print — would hold a throw where it started, so a click there throws nothing at all: the model asks a piece whether its animation reads as paused, and leaves the stage exactly as it is.--x, --y, --r — the place a per cent of the canvas across and down — so a paused card shows confetti and not just a caption, and the scene covers the canvas as a full-canvas model must. A click adds .popped: each piece flicks outward, away from the middle its own place points away from, shrinking and fading as the burst takes over, and a timer takes the class off again. Their resting rule jumps transform back at once but fades opacity in slowly, so they reappear in place instead of flying back.inset: 0), the pieces lie over all of it, and a burst flies out to its edges. Places and flights are shares of the canvas — container-type: size makes 1cqw and 1cqh one per cent of it across and down — so the scene covers a gallery card, a tall 9:16 export and a full screen alike; only a piece's own size is in the base unit --u. A burst starts where the click or the tap did: JS turns the point into --ox / --oy, per cent of the canvas across and down, so it lands under the pointer however the stage has scaled the frame, and a keyboard throw (Enter or Space) bursts from the middle.random --x / --y per piece, in per cent of the canvas, plus --z and --spintranslate3d + rotate3d keyframesa pool of pieces thrown again and again<div class="party" role="button" tabindex="0" aria-label="Throw confetti">
<span class="waiting" aria-hidden="true">
<b style="--x:1;--y:3;--r:0deg;--c:#ff4d9d"></b>
<b style="--x:20;--y:11;--r:47deg;--c:#8b6cff"></b>
<b style="--x:37;--y:0;--r:94deg;--c:#2ee6d6"></b>
<b style="--x:54;--y:9;--r:141deg;--c:#ffb547"></b>
<b style="--x:71;--y:4;--r:188deg;--c:#ff4d9d"></b>
<b style="--x:88;--y:12;--r:235deg;--c:#8b6cff"></b>
<b style="--x:100;--y:26;--r:282deg;--c:#2ee6d6"></b>
<b style="--x:0;--y:22;--r:329deg;--c:#ffb547"></b>
<b style="--x:16;--y:30;--r:16deg;--c:#ff4d9d"></b>
<b style="--x:33;--y:19;--r:63deg;--c:#8b6cff"></b>
<b style="--x:67;--y:22;--r:110deg;--c:#2ee6d6"></b>
<b style="--x:84;--y:31;--r:157deg;--c:#ffb547"></b>
<b style="--x:0;--y:50;--r:204deg;--c:#ff4d9d"></b>
<b style="--x:12;--y:62;--r:251deg;--c:#8b6cff"></b>
<b style="--x:89;--y:45;--r:298deg;--c:#2ee6d6"></b>
<b style="--x:100;--y:62;--r:345deg;--c:#ffb547"></b>
<b style="--x:7;--y:74;--r:32deg;--c:#ff4d9d"></b>
<b style="--x:24;--y:80;--r:79deg;--c:#8b6cff"></b>
<b style="--x:41;--y:71;--r:126deg;--c:#2ee6d6"></b>
<b style="--x:58;--y:77;--r:173deg;--c:#ffb547"></b>
<b style="--x:76;--y:70;--r:220deg;--c:#ff4d9d"></b>
<b style="--x:95;--y:84;--r:267deg;--c:#8b6cff"></b>
<b style="--x:30;--y:100;--r:314deg;--c:#2ee6d6"></b>
<b style="--x:63;--y:99;--r:1deg;--c:#ffb547"></b>
</span>
click anywhere
</div>
/* A full-canvas scene (VIEW-CONTRACT.md): "click anywhere" is the whole canvas, so the pieces
lie all over it and the burst flies to its edges. The scene is inset: 0 and every place in it is
a share of the canvas — 1cqw and 1cqh, one per cent across and down — so it covers the canvas
whatever shape the canvas is. Only a piece's own size is in the base unit. */
.party {
/* one base unit, tied to the canvas: a piece's own size is measured in it */
--u: 0.1vmin;
position: fixed;
inset: 0;
container-type: size; /* inside it, 1cqw and 1cqh are 1% of the canvas across and down */
display: grid;
place-items: center;
overflow: hidden;
perspective: calc(850 * var(--u));
color: inherit; /* the stage's own ink: a fixed pale lavender was 2.5:1 on the light stage */
font: 700 calc(62 * var(--u)) Inter, system-ui, sans-serif;
cursor: pointer;
user-select: none;
}
/* the pieces lying about at rest: over the whole canvas, each one's place a per cent of it
(--x across, --y down, written into the HTML) and each tilted its own way in 3D */
.waiting {
position: absolute;
inset: 0;
transform-style: preserve-3d;
pointer-events: none;
}
.waiting b {
position: absolute;
left: calc(var(--x) * 1cqw);
top: calc(var(--y) * 1cqh);
width: calc(30 * var(--u));
height: calc(42 * var(--u));
margin: calc(-21 * var(--u)) 0 0 calc(-15 * var(--u));
border-radius: calc(6 * var(--u));
background: var(--c);
transform: rotate3d(1, 1, 0.4, var(--r)) scale(1);
/* coming back: in place at once (still invisible), then a slow fade in */
transition: transform 0s, opacity 0.6s ease 0.1s;
}
/* a click flicks them outward from the middle as they shrink and fade, handing over to the
burst: a piece's place tells it which way out is */
.popped .waiting b {
opacity: 0;
transform: translate3d(calc((var(--x) - 50) * 0.3cqw), calc((var(--y) - 50) * 0.3cqh), 0) rotate3d(1, 1, 0.4, calc(var(--r) + 180deg)) scale(0.2);
transition: transform 0.35s ease-out, opacity 0.3s linear;
}
/* a burst starts where the click did — JS writes the point as --ox / --oy, per cent of the canvas
across and down (the middle when the keyboard threw it) — and flies out across the canvas */
.party i {
position: absolute;
left: calc(var(--ox, 50) * 1cqw);
top: calc(var(--oy, 50) * 1cqh);
width: calc(30 * var(--u));
height: calc(42 * var(--u));
margin: calc(-21 * var(--u)) 0 0 calc(-15 * var(--u));
border-radius: calc(6 * var(--u));
background: hsl(var(--hue) 90% 62%);
pointer-events: none;
/* a piece waiting to be thrown is not on screen: the pieces are made once and thrown again and
again, so between throws they wait here, invisible */
opacity: 0;
}
/* thrown: the class is what starts the flight, so taking it off and putting it back throws the
same piece again */
.party i.fly {
animation: fly 1.3s cubic-bezier(0.1, 0.7, 0.3, 1) forwards;
}
/* JS writes --x, --y and --z as plain numbers: --x and --y are per cent of the canvas from its
middle, so a burst reaches the edges of any canvas, and --z is depth in the base unit */
@keyframes fly {
/* bright until the burst has nearly spread, then gone quickly: faded on the same curve as the
flight, a particle is half see-through before it is halfway out, and the burst reads small */
from {
opacity: 1;
}
50% {
opacity: 1;
}
to {
opacity: 0;
/* + 18% of the canvas on Y is the "gravity" */
transform:
translate3d(calc(var(--x) * 1cqw), calc((var(--y) + 18) * 1cqh), calc(var(--z) * var(--u)))
rotate3d(1, 1, 0.4, var(--spin));
}
}
/* the party covers the canvas, so a ring round it would be off the canvas: focus lights the words */
.party:focus-visible {
outline: none;
color: #2ee6d6;
}
const party = document.querySelector('.party');
const rand = (min, max) => min + Math.random() * (max - min);
const PER_BURST = 36; // 18 vectors, each thrown left and right
const POOL = PER_BURST * 3; // pieces that exist at all: three bursts' worth, thrown again and again
/**
* Whether the stage this runs on is holding its animations (the site's Pause, the export
* dialog's held pose, a print). A piece made now would hang in the air for good: it would
* never fly and never reach animationend. Asked of a real piece, so it is the CSS that
* answers, whatever pauses it.
*/
function held() {
const probe = document.createElement('i');
probe.className = 'fly'; // the class that carries the animation
party.append(probe);
const state = getComputedStyle(probe).animationPlayState;
probe.remove();
return state === 'paused';
}
// The pieces are made once and thrown again and again: clicking fast is then only new numbers on
// elements that already exist, not a hundred of them made and unmade while the page is drawing.
const pieces = [];
let next = 0;
let settle;
/**
* Where a burst starts, as per cent of the canvas: the point the pointer is on, or the middle
* when the keyboard threw it. A ratio of the canvas, not pixels, so it lands under the pointer
* however the stage has scaled the frame.
*/
function from(point) {
const root = document.documentElement;
if (!point) return { ox: 50, oy: 50 };
const clamp = (v) => Math.round(Math.max(0, Math.min(100, v)) * 100) / 100;
return { ox: clamp((point.clientX / root.clientWidth) * 100), oy: clamp((point.clientY / root.clientHeight) * 100) };
}
function burst(point) {
if (held()) return; // a paused stage stays exactly as it is, click or no click
const { ox, oy } = from(point);
// the pieces waiting round the words go with the burst, and come back once it has fallen
party.classList.add('popped');
clearTimeout(settle);
settle = setTimeout(() => party.classList.remove('popped'), 1500);
// 18 random vectors, each thrown twice, once to the left and once to the right, so the burst
// is balanced around the middle however the dice fall; a little jitter keeps the two from
// reading as a mirror image. Every number is a per cent of the canvas from its middle, so the
// burst is the same share of a gallery card and a full screen. Each height is drawn from its
// own eighteenth of the range, so the burst always reaches as high and as low: -68 to 32,
// which the 18 of gravity turns into -50 to 50 — the canvas, top to bottom.
// the 36 pieces this throw uses: the next of the pool, made the first time round; past its end
// they are the oldest ones still in the air, taken back for this throw
const batch = [];
for (let i = 0; i < PER_BURST; i++) {
let p = pieces[next % POOL];
if (!p) { p = document.createElement('i'); pieces[next % POOL] = p; party.append(p); }
next++;
batch.push(p);
}
// Off, then on again, is what throws a piece afresh. The class comes off all 36 first, the page
// is measured once (that one read is what makes the browser notice), and the new values and the
// class go on: one style pass for the whole burst, however fast the clicks come.
for (const p of batch) p.classList.remove('fly');
void party.offsetWidth;
let at = 0;
for (let i = 0; i < 18; i++) {
const x = rand(0, 52), y = -68 + (i + rand(0.2, 0.8)) * 100 / 18, z = rand(-100, 100);
for (const side of [-1, 1]) {
const p = batch[at++];
// plain numbers: the CSS reads --x and --y as per cent of the canvas, so the burst is the
// same share of a gallery card, a full screen and a recording canvas
p.style.setProperty('--x', side * x + rand(-5, 5));
p.style.setProperty('--y', y + rand(-2.5, 2.5));
p.style.setProperty('--z', z);
p.style.setProperty('--spin', rand(360, 1080) + 'deg');
p.style.setProperty('--hue', rand(0, 360));
// where this burst started: the CSS puts the piece there before it flies
p.style.setProperty('--ox', ox);
p.style.setProperty('--oy', oy);
p.classList.add('fly');
}
}
}
party.addEventListener('pointerdown', (e) => burst(e));
// it is a button to the keyboard too: Enter or Space throws it
party.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
burst();
});