Kaleidoscope how it’s made

The idea

You look down the eyepiece of a brass kaleidoscope. Twelve fixed mirror slices fold the glass fragments (beads, rings and shards) in the object cell into a six-fold pattern. The barrel turns on its own, forever: the cell turns, the loose glass inside tumbles and shakes, and the pattern keeps changing without ever repeating. Where CSS random() is supported, every visit also starts from a different pattern. In dark mode the glass glows like jewels under a lamp; in light mode it filters daylight like stained glass. No JavaScript, no images, no web fonts.

Techniques

Mirrored clip-path wedges (rotate + scaleY(-1))

A real kaleidoscope is two mirrors at an angle. Here each wedge is a box from the centre, r wide and r · tan(a) tall, so its diagonal sits at exactly a degrees. A clip-path triangle keeps the slice between 0 and a:

.scope b {
  --a: calc(360deg / var(--n));
  --k: 0;
  position: absolute;
  inset: 50% auto auto 50%;
  inline-size: var(--r);
  block-size: calc(var(--r) * tan(var(--a)));
  transform-origin: 0 0;
  transform: rotate(calc(var(--k) * 2 * var(--a)));
  clip-path: polygon(0 0, 100% -1%, 100% 101%); /* a hair wider than the wedge hides anti-aliased seams */
}
.scope b:nth-child(even) { transform: rotate(calc((var(--k) * 2 + 2) * var(--a))) scaleY(-1); }

Wedges come in pairs. The odd one sits at 2ka. The even one is flipped with scaleY(-1), which maps the slice to −a…0, then rotated one pair further, so it lands at 2ka + a … 2ka + 2a. Every seam therefore meets its own reflection, which is why the joins are invisible. The polygon is a hair wider than the wedge so anti-aliased edges overlap instead of showing a dark hairline.

sibling-count() and sibling-index() fold count with :nth-child fallback

The fold count comes from the markup. Where sibling-index() is supported, --n is the number of wedges and --k (the pair index) is computed per wedge:

@supports (order: sibling-index()) {
  .scope b { --n: sibling-count(); --k: round(down, (sibling-index() - 1) / 2); }
}

Delete four <b> elements and you get four-fold symmetry with no CSS change (checked in Chrome). Keep the count even and at least 6, since tan(90deg) is infinite. sibling-index() is newly available (2026-08-18). Without it, --n: 12 and five :nth-child(n + 3)… rules set --k, so the shipped markup looks the same everywhere.

Fixed mirrors over a turning object cell with tumbling fragment layers

In a real kaleidoscope the mirrors never move. Turning the barrel turns the object cell at the far end, and the loose glass in it tumbles: pieces catch, ride along, then slip and fall over each other. That is why the pattern changes instead of the whole picture spinning. The scene copies that. The wedges (the mirrors) and .scope have no animation at all; only the glass moves.

CSS has no way to render one element in twelve places, so every wedge holds its own copy of the cell. The copies stay perfectly symmetric because they share the same styles and the same timeline.

.scope i,
.scope i::before,
.scope i::after {
  position: absolute;
  inset: calc(var(--r) * -0.5) auto auto calc(var(--r) * -0.18);
  inline-size: calc(var(--r) * 1.45);
  aspect-ratio: 1;
  animation: cell var(--turn, 80s) linear infinite, wander-a 23s ease-in-out infinite alternate;
}

There are three layers, each with its own resting rotate and its own off-centre transform-origin:

The loose layers wander with a small translate at each step, and their timing function applies per keyframe segment, so each step holds and then slips:

.scope i::before {
  animation-name: tumble;
  animation-timing-function: cubic-bezier(0.7, 0, 0.3, 1); /* per segment: hold, then slip */
}
@keyframes cell { to { transform: rotate(1turn); } }
@keyframes tumble {
  9% { transform: rotate(-30deg) translate(4%, -2%); }
  17% { transform: rotate(-45deg) translate(1%, 3%); }
  /* …uneven steps… */
  100% { transform: rotate(-360deg); }
}

Because the three layers move at different, uneven rates, they slide past each other relative to the fixed mirrors, and the folded pattern keeps re-forming. The resting angle lives in the rotate property and the animation adds to it through transform; both apply, so turning animation off leaves a composed still frame.

The brass ring is a .tube::before that runs the same cell keyframes on the same --turn period, so its highlights turn with the barrel. The eyepiece glint on .tube::after stays put.

Everything that moves is a transform, so it runs on the compositor. The gradients paint once per layer and are then only moved.

mix-blend-mode switched by colour scheme

mix-blend-mode can't take light-dark(), but it can take a custom property:

--blend: screen; /* overlapping glass adds light on black... */
@media (prefers-color-scheme: light) {
  .stage { --blend: multiply; } /* ...and filters it like stained glass held up to a window */
}

The second fragment layer uses mix-blend-mode: var(--blend). On black, screen makes overlaps brighter; on the pale daylight field, multiply makes them deeper, and the light palette uses translucent colours so the effect reads.

Endless barrel turn with coprime animation periods

Every layer runs two infinite animations: a turn on transform and an irregular wander on the separate translate property, which composes with it. The six periods are 80s, 47s, 31s, 13s, 19s and 23s. They share no common factor, so the layers only line up the same way again after their least common multiple, about 6.6 × 10⁸ seconds (around twenty years, counting the alternating wanders). In practice the pattern never repeats.

.scope i::before {
  animation-name: tumble, wander-b;
  animation-duration: 47s, 13s;
  animation-timing-function: cubic-bezier(0.7, 0, 0.3, 1), ease-in-out;
}
@keyframes wander-b {
  17% { translate: -4% 2%; }
  39% { translate: 3% 5%; }
  /* …irregular steps… */
}

The wander runs alternate, so it never jumps at the loop point, and its steps are uneven so it reads as glass shaking loose rather than an orbit. An earlier version turned the barrel from a scroll(root) timeline; it stopped when you stopped scrolling and ended at the bottom of the page, so the scene now always runs on time. The gallery's playback controls (speed, pause) act on these animations.

Per-visit random() with element-shared, keeping the mirrors symmetric

Random positions per wedge would break the kaleidoscope: every mirror has to show the same glass. CSS random() handles this with element-shared, which gives every element the same value, while a named key keeps each layer's value independent:

@supports (rotate: random(--k element-shared, 0deg, 1turn)) {
  .stage { --turn: random(--turn element-shared, 60s, 100s, 1s); }
  .scope i::before {
    rotate: random(--tumble element-shared, 0deg, 1turn);
    animation-delay: random(--tumble-t element-shared, -47s, 0s), random(--tumble-w element-shared, -13s, 0s);
  }
}

Each layer starts at a random resting angle and a random point in each of its cycles (negative delays), and the barrel's speed is picked between 60s and 100s a turn. random() is limited availability (Safari 26.2 as far as we know; not in Chrome 153, checked with CSS.supports). Without it, the scene starts from the same pattern on every visit and then never repeats.

Optional adaptive size: a small script fits the eyepiece to the frame rate

The scene is CSS only until you turn on Fit size to frame rate in Controls. The switch sets data-adaptive on the stage, and a small scene.js starts counting frames. Once a second it compares the frame rate with the display's refresh rate. Below 90% of that rate, the eyepiece shrinks by 12% at once. After two smooth seconds in a row (at least 95%), it grows by 4%. It doesn't grow straight back to a size that dropped frames, but it tries again after thirty smooth seconds. Its size stays between 30% and 92% of the shorter side of the stage.

A page can't read the refresh rate, and the rate changes: a Mac on battery drops from 60 Hz to 30 Hz. So the script works it out:

I checked the logic with a simulated display and frame cost (60 Hz → 30 Hz → 60 Hz). It settles near the largest size that keeps up at 60 Hz, grows to full size at 30 Hz, and comes back down when plugged in again.

The script writes the size to --fit-d, which the stage reads in place of its default size:

--d: var(--fit-d, min(84cqmin, 42rem));

Turn the switch off and the property is removed, so the eyepiece goes back to min(84cqmin, 42rem).

GPU memory budget

When the GPU runs out of tile memory, wedges silently stop drawing while the frame rate holds. No web API reports that: frame timing, paint timing and IntersectionObserver describe what the page asked for, not what the GPU managed to draw. So scene.js predicts the memory instead, and GPU memory budget in Controls (40–600 MB, default 150) caps the eyepiece so the estimate stays within it.

The model is fitted to Chromium memory dumps (cc/tile_memory at 1400×900 and 2×). It is a fixed cost for the page, which scales with window area, plus a cost per wedge and per extra fragment layer, which scales with the eyepiece's area. Both grow with the pixel density squared:

memory ≈ dpr² × (window area × 8 B + r² × mirrors × (4.6 B + 1.7 B × (layers − 1)))

It matches the dumps it was fitted to: 65 MB at 12/1 and 145 MB at 24/4. With a budget of 80 MB at 24/4, the eyepiece shrinks to 414 px, and the dump settles at 66 MB once the browser frees the old tiles, which took a few seconds. The cap goes in --cap-d, the stage reads min(var(--fit-d, …), var(--cap-d)), and the frame-rate fitting stays under the cap. data-memory on the stage shows the current estimate in MB. The real limit depends on the GPU and browser, so the slider is for lowering the budget until nothing drops out.

Extra fragment layers and mirrors from Controls

Controls can add mirrors and fragment layers by cloning elements. A clone's CSS animations start when it is inserted, so before this fix added wedges ran behind the originals, and part of the circle stopped matching the rest. The panel now gives each clone's animations the start time of the matching animation on an existing sibling, so every wedge stays on the same clock. Each added fragment layer is only loose beads (no rings or shards) turned to a new angle, so more layers add sparkle instead of clutter. The mirror count goes from 6 (4 mirrors would need tan(90deg)) to 24, and fragment layers go up to 4. Every wedge holds its own composited, animated copy of each layer, and the browser rasters each layer in full even though clip-path shows only a sliver. At high counts the GPU tile budget runs out, and some wedges or the Controls sheet simply don't draw, while the frame rate stays the same. A Chromium memory dump at 1400×900 and 2× shows the tile memory: 85 MB at 12 mirrors and 1 layer, and 319 MB at 32 mirrors and 4 layers in the earlier version. Two changes bring that down:

A rectangular overflow: clip on each wedge does not reduce raster memory (checked with the same dump), so the wedges keep clip-path alone.

The eyepiece is sized from the stage's own box (container-type: size and cqmin), not from the viewport. The gallery's chrome insets the stage, so the stage can be smaller than the window. While a floating sheet (About or Stats) is open on a window at least 48rem wide, the panel sets data-sheet-open on <html>, and the scene pads the stage by the sheet's width so the sheet never sits over the glass. The container units then shrink the eyepiece to the room that is left. The glow behind the eyepiece uses background-origin: content-box, so it stays centred on the eyepiece.

Performance notes

rawgzipbrotlibudgetok
15,355 B5,222 B4,468 B14,336 Byes

All motion is transform or opacity. The costliest part is 36 clipped, composited fragment layers plus one blend per wedge. An earlier requestAnimationFrame sample in Chrome, with the barrel scroll-driven, held 61 fps; the extra translate animations are compositor-only too, but the endless version has not been re-measured. Lighthouse and dropped-frame stats are not measured yet (TODO: pnpm measure once it exists).

The eyepiece is capped at min(84cqmin, 42rem) (672px) rather than filling the window, which keeps every wedge's layer small.

Accessibility

Reuse

Uses lib/stage. Candidate snippet: the mirrored-wedge rig. Anything placed inside .scope i becomes kaleidoscopic.

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Kaleidoscope</title>
<!--css-->
<main class="stage" aria-label="Looking down a brass kaleidoscope. Fixed mirrors fold the coloured glass beads and shards in the object cell into a six-fold pattern. The barrel turns endlessly, tumbling the glass so the pattern keeps changing and never repeats.">
<div class="tube" aria-hidden="true">
<div class="scope"><b><i></i></b><b><i></i></b><b><i></i></b><b><i></i></b><b><i></i></b><b><i></i></b><b><i></i></b><b><i></i></b><b><i></i></b><b><i></i></b><b><i></i></b><b><i></i></b></div>
</div>
</main>
@import "lib/stage";

@layer scene {
  .stage {
    /* Eyepiece size; every wedge and glass piece is sized from --r so the scope scales as one */
    /* Sized from the stage's own box (cqmin), not the viewport: the gallery's controls column and an open sheet
       make the stage smaller than the window. --fit-d: set by scene.js when adaptive size is on. */
    container-type: size;
    --budget: 150MB; /* GPU memory budget; the Controls slider overrides it */
    --d: min(var(--fit-d, min(84cqmin, 42rem)), var(--cap-d, 100cqmin)); /* --cap-d: set by scene.js from --budget */
    --r: calc(var(--d) / 2);
    --rim: max(10px, 1.8cqmin);
    /* Local glass colours (light-dark: daylight stained glass / lamp-lit jewels) */
    --ruby: light-dark(#e8003dcc, #ff2d55);
    --amber: light-dark(#ff9d00cc, #ffb000);
    --teal: light-dark(#00a891cc, #00d1b2);
    --cobalt: light-dark(#1f5bffbb, #3d7bff);
    --violet: light-dark(#9a3dffbb, #b366ff);
    --lime: light-dark(#8fd400cc, #c4ff3d);
    --field: light-dark(#f7f2e6, #07060c);
    --blend: screen; /* overlapping glass adds light on black... */
    display: grid;
    place-items: center;
    background:
      radial-gradient(circle, light-dark(#0000, #2a1c4a66) 0 calc(var(--r) + var(--rim)), #0000 70%) content-box no-repeat,
      light-dark(#e6dfd0, #0d0b14);
  }
  /* Make room for the Controls sheet on wide windows, so it never sits over the glass: a sheet over this many
     animated layers can fail to paint (it showed as an empty translucent box), and its blur would re-read them. */
  @media (width >= 48rem) {
    :root[data-sheet-open] .stage {
      padding-inline-end: calc(min(22rem, 100vw - 1.5rem) + 1.5rem);
    }
  }
  @media (prefers-color-scheme: light) {
    .stage { --blend: multiply; } /* ...and filters it like stained glass held up to a window */
  }

  /* Brass barrel around the eyepiece */
  .tube {
    position: relative;
    padding: var(--rim);
    border-radius: 50%;
    box-shadow: 0 2cqmin 6cqmin #0008, 0 0 0 1px #0006;
  }
  /* the barrel's brushed-brass highlights turn with the object cell, so you can see the tube turning */
  .tube::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: conic-gradient(from 40deg, #7a5a24, #f0d48c, #8c6a2c, #5e4419, #e2bf6c, #7a5a24);
    animation: cell var(--turn, 80s) linear infinite;
  }
  /* Eyepiece glint and depth shadow sit on the barrel, so they stay put while the view turns */
  .tube::after {
    content: "";
    position: absolute;
    inset: var(--rim);
    border-radius: 50%;
    background: radial-gradient(circle at 32% 26%, #fff4, #fff0 28%);
    box-shadow: inset 0 0 calc(var(--r) * 0.18) #000a, inset 0 0 0 1px #0008;
  }

  .scope {
    --n: 12; /* wedge count; sibling-count() reads it from the markup where supported */
    position: relative;
    inline-size: var(--d);
    aspect-ratio: 1;
    border-radius: 50%;
    overflow: clip;
    background: radial-gradient(circle, light-dark(#fffdf6, #1a1430), var(--field) 75%);
  }

  /* One wedge = a right triangle from the centre whose angle is 360deg / n.
     Odd wedges sit at k * 2a; even ones are mirror images (scaleY(-1)) rotated one wedge further,
     so every seam meets its own reflection. */
  .scope b {
    --a: calc(360deg / var(--n));
    --k: 0;
    position: absolute;
    inset: 50% auto auto 50%;
    inline-size: var(--r);
    block-size: calc(var(--r) * tan(var(--a)));
    transform-origin: 0 0;
    transform: rotate(calc(var(--k) * 2 * var(--a)));
    clip-path: polygon(0 0, 100% -1%, 100% 101%); /* a hair wider than the wedge hides anti-aliased seams */
  }
  .scope b:nth-child(even) { transform: rotate(calc((var(--k) * 2 + 2) * var(--a))) scaleY(-1); }
  .scope b:nth-child(n + 3) { --k: 1; }
  .scope b:nth-child(n + 5) { --k: 2; }
  .scope b:nth-child(n + 7) { --k: 3; }
  .scope b:nth-child(n + 9) { --k: 4; }
  .scope b:nth-child(n + 11) { --k: 5; }
  .scope b[style] { --k: round(down, (var(--i) - 1) / 2); } /* the controls panel adds mirrors and sets --i on each */
  @supports (order: sibling-index()) {
    .scope b { --n: sibling-count(); --k: round(down, (sibling-index() - 1) / 2); }
  }

  /* The object cell. The mirrors (wedges) never move; the barrel turns the cell in front of them forever,
     and the loose glass inside it tumbles: it catches, lags behind the cell, then slips and falls.
     Every wedge holds an identical copy on the same clock, so the copies stay in perfect symmetry.
     Two animations per layer: a turn on `transform` and a wander on `translate`. Their periods are
     coprime (80s, 47s, 31s, 13s, 19s, 23s), so the layers never line up the same way twice in a sitting
     and the pattern never repeats; the full loop only closes after about twenty years. */
  .scope i,
  .scope i::before,
  .scope i::after {
    position: absolute;
    inset: calc(var(--r) * -0.5) auto auto calc(var(--r) * -0.18);
    inline-size: calc(var(--r) * 1.45);
    aspect-ratio: 1;
    animation: cell var(--turn, 80s) linear infinite, wander-a 23s ease-in-out infinite alternate;
  }
  .scope i {
    transform-origin: 52% 48%;
    rotate: 35deg; /* resting angles are the reduced-motion frame */
    background:
      radial-gradient(circle at 31% 35%, #fffb 0 0.9%, #fff0 1.3%),
      radial-gradient(circle at 33% 37%, var(--ruby) 0 6%, #0000 6.4%),
      radial-gradient(circle at 63% 61%, #fff9 0 0.6%, #fff0 1%),
      radial-gradient(circle at 64% 62%, var(--teal) 0 4%, #0000 4.4%),
      radial-gradient(circle at 47% 74%, var(--amber) 0 3.2%, #0000 3.6%),
      radial-gradient(circle at 71% 29%, var(--lime) 0 2.2%, #0000 2.6%),
      radial-gradient(circle at 52% 50%, #0000 0 9%, var(--amber) 9.3% 9.9%, #0000 10.2%),
      conic-gradient(from 200deg at 56% 44%, var(--cobalt) 0 16deg, #0000 16.6deg 150deg, var(--violet) 150.6deg 166deg, #0000 166.6deg),
      conic-gradient(from 12deg at 40% 58%, var(--amber) 0 20deg, #0000 20.6deg 190deg, var(--teal) 190.6deg 200deg, #0000 200.6deg);
  }
  /* Extra glass from the controls panel: each added layer is only loose beads (no rings or shards),
     turned to a new angle, so more layers add sparkle instead of clutter. */
  .scope i + i { rotate: calc(35deg + var(--i) * 137deg); background: none; }
  .scope i + i::before { display: none; }
  .scope i::before {
    content: "";
    inset: -12%;
    inline-size: auto;
    transform-origin: 44% 58%;
    rotate: -80deg;
    mix-blend-mode: var(--blend);
    animation-name: tumble, wander-b;
    animation-duration: 47s, 13s;
    animation-timing-function: cubic-bezier(0.7, 0, 0.3, 1), ease-in-out; /* per segment: hold, then slip */
    background:
      radial-gradient(circle at 38% 52%, var(--violet) 0 5%, #0000 5.4%),
      radial-gradient(circle at 60% 40%, var(--ruby) 0 2.6%, #0000 3%),
      radial-gradient(circle at 55% 66%, var(--lime) 0 3.6%, #0000 4%),
      radial-gradient(ellipse 9% 3% at 44% 34%, var(--cobalt) 0 98%, #0000),
      conic-gradient(from 90deg at 50% 50%, #0000 0 30deg, var(--teal) 30.6deg 44deg, #0000 44.6deg 210deg, var(--ruby) 210.6deg 222deg, #0000 222.6deg),
      repeating-conic-gradient(from 0deg at 62% 54%, color-mix(in oklab, var(--amber) 45%, #0000) 0 2deg, #0000 2.8deg 24deg);
  }
  .scope i::after {
    content: "";
    inset: 20.24%; /* only as big as the beads need: every layer is rastered in full, in every wedge */
    inline-size: auto;
    transform-origin: 66.7% 41.7%;
    rotate: 140deg;
    animation-name: rattle, wander-c;
    animation-duration: 31s, 19s;
    animation-timing-function: cubic-bezier(0.8, 0, 0.2, 1), ease-in-out;
    background:
      radial-gradient(circle calc(var(--r) * 0.0290) at 41.7% 29.2%, var(--amber) 0 80%, #0000),
      radial-gradient(circle calc(var(--r) * 0.0220) at 54.2% 37.5%, var(--teal) 0 75%, #0000),
      radial-gradient(circle calc(var(--r) * 0.0330) at 64.6% 72.9%, var(--ruby) 0 82%, #0000),
      radial-gradient(circle calc(var(--r) * 0.0291) at 20.8% 77.1%, var(--cobalt) 0 78%, #0000),
      conic-gradient(from 300deg at 83.3% 50.0%, var(--violet) 0 14deg, #0000 14.6deg);
  }
  /* Transforms add to each layer's resting `rotate`. The cell turns steadily; the loose layers turn against it
     and wander, in uneven steps, so relative to the mirrors the pieces tumble instead of just spinning. */
  @keyframes cell { to { transform: rotate(1turn); } }
  @keyframes tumble {
    9% { transform: rotate(-30deg) translate(4%, -2%); }
    17% { transform: rotate(-45deg) translate(1%, 3%); }
    26% { transform: rotate(-85deg) translate(-4%, 2%); }
    34% { transform: rotate(-100deg) translate(-2%, -3%); }
    45% { transform: rotate(-160deg) translate(3%, -4%); }
    52% { transform: rotate(-172deg) translate(5%, 1%); }
    63% { transform: rotate(-230deg) translate(-1%, 4%); }
    71% { transform: rotate(-250deg) translate(-5%, 0); }
    82% { transform: rotate(-305deg) translate(-2%, -4%); }
    90% { transform: rotate(-325deg) translate(2%, -1%); }
    100% { transform: rotate(-360deg); }
  }
  @keyframes rattle {
    12% { transform: rotate(45deg) translate(-6.2%, 6.2%); }
    20% { transform: rotate(60deg) translate(4.2%, 10.4%); }
    33% { transform: rotate(150deg) translate(8.3%, -2.1%); }
    41% { transform: rotate(170deg) translate(0, -8.3%); }
    55% { transform: rotate(260deg) translate(-8.3%, -4.2%); }
    62% { transform: rotate(280deg) translate(-4.2%, 6.2%); }
    76% { transform: rotate(310deg) translate(6.2%, 8.3%); }
    84% { transform: rotate(330deg) translate(8.3%, -4.2%); }
    100% { transform: rotate(360deg); }
  }

  /* Wander: the loose glass drifts on the separate `translate` property, which composes with the turn.
     Steps are deliberately irregular so the drift reads as shaking, not as an orbit. */
  @keyframes wander-a {
    0% { translate: 0 0; }
    21% { translate: 1.5% -1%; }
    48% { translate: -1% -2%; }
    77% { translate: -2% 1.5%; }
    100% { translate: 1% 2%; }
  }
  @keyframes wander-b {
    0% { translate: 0 0; }
    17% { translate: -4% 2%; }
    39% { translate: 3% 5%; }
    58% { translate: 5% -2%; }
    83% { translate: -2% -5%; }
    100% { translate: 2% -1%; }
  }
  @keyframes wander-c {
    0% { translate: 0 0; }
    13% { translate: 10.4% 6.2%; }
    36% { translate: -6.2% 12.5%; }
    64% { translate: -12.5% -4.2%; }
    88% { translate: 4.2% -10.4%; }
    100% { translate: 8.3% 2.1%; }
  }

  /* Per-visit randomness where CSS random() is supported: each layer starts at a random angle and a random
     point in its cycles, so every visit shows a different pattern. element-shared gives every wedge's copy the
     same value, which keeps the mirrors symmetric; the named keys keep each layer's values independent. */
  @supports (rotate: random(--k element-shared, 0deg, 1turn)) {
    .stage { --turn: random(--turn element-shared, 60s, 100s, 1s); }
    .scope i {
      rotate: random(--cell element-shared, 0deg, 1turn);
      animation-delay: random(--cell-t element-shared, -100s, 0s), random(--cell-w element-shared, -23s, 0s);
    }
    .scope i::before {
      rotate: random(--tumble element-shared, 0deg, 1turn);
      animation-delay: random(--tumble-t element-shared, -47s, 0s), random(--tumble-w element-shared, -13s, 0s);
    }
    .scope i::after {
      rotate: random(--rattle element-shared, 0deg, 1turn);
      animation-delay: random(--rattle-t element-shared, -31s, 0s), random(--rattle-w element-shared, -19s, 0s);
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .stage *, .stage *::before, .stage *::after { animation: none !important; }
  }
}
// Adaptive size (off by default; the "Fit size to frame rate" switch in Controls sets data-adaptive on the stage).
// Once a second it compares the frame rate with the display's refresh rate: it shrinks the eyepiece when frames
// drop and grows it while there is headroom. The size goes in --fit-d, which scene.css reads in place of --d.
//
// The page can't read the refresh rate, and it changes (a Mac on battery drops from 60 Hz to 30 Hz), so the script
// works it out. Short frame gaps reveal a higher rate. A lower one shows up when shrinking stops helping: the frame
// rate sits on a display rate however small the eyepiece gets, so the rate becomes the new target and the size goes
// back to where it was. While the target is under 60, it now and then shrinks for a second to test for a faster display.
const stage = document.querySelector('.stage');
const RATES = [24, 30, 48, 60, 75, 90, 100, 120, 144, 165, 240];
const snap = (hz) => RATES.reduce((best, r) => (Math.abs(r - hz) < Math.abs(best - hz) ? r : best));
let raf = 0, last = 0, since = 0, gaps = [];
let target = 0, size = 0, ceiling = Infinity, calm = 0;
let shrinks = 0, before = 0, beforeFps = 0; // the current run of shrinks
let probing = false, probeIn = 15, probeSize = 0, probeTarget = 0;

const bounds = () => {
  // The stage's content box: it narrows while the Controls sheet is open
  const side = Math.min(stage.clientWidth - parseFloat(getComputedStyle(stage).paddingInlineEnd), stage.clientHeight);
  const cap = parseFloat(stage.style.getPropertyValue('--cap-d')) || Infinity; // the memory budget's limit
  return [Math.min(side * 0.3, cap), Math.min(side * 0.92, cap)];
};
const apply = (next) => {
  const [min, max] = bounds();
  size = Math.min(max, Math.max(min, next));
  stage.style.setProperty('--fit-d', `${Math.round(size)}px`);
};
const step = (fps, fastest) => {
  if (!target) { target = Math.max(60, fastest); return; } // assume at least 60 Hz until shown otherwise
  if (fastest > target) { target = fastest; ceiling = Infinity; probeIn = 15; } // frames show a faster display

  if (probing) { // last second ran small: did that reveal a faster display? If not, put the size back
    probing = false;
    if (target <= probeTarget) { apply(probeSize); probeIn *= 2; calm = 0; }
    return;
  }

  if (fps < target * 0.9) { // dropping frames
    if (shrinks >= 2 && fps <= beforeFps * 1.05 && Math.abs(fps - snap(fps)) < 2) {
      // Shrinking didn't help and the rate sits on a display rate: that's the display. Undo the shrinks.
      target = snap(fps); apply(before); shrinks = 0; ceiling = Infinity; probeIn = 15; calm = 0;
      return;
    }
    if (!shrinks) { before = size; beforeFps = fps; }
    shrinks++; ceiling = size * 0.95; calm = 0;
    apply(size * 0.88);
    return;
  }
  shrinks = 0;
  if (fps < target * 0.95) return;

  calm++;
  if (calm >= 30) ceiling = Infinity; // after thirty smooth seconds, sizes that dropped frames get another try
  if (target < 60 && --probeIn <= 0) { probing = true; probeSize = size; probeTarget = target; apply(size * 0.6); return; }
  if (calm >= 2) apply(Math.min(size * 1.04, Math.max(ceiling, size))); // two smooth seconds: grow a little
};
const tick = (now) => {
  raf = requestAnimationFrame(tick);
  if (!last || now - last > 500) { // first frame, or back from a hidden tab: start a fresh window
    last = since = now; gaps = []; size ||= stage.querySelector('.scope').offsetWidth;
    return;
  }
  gaps.push(now - last);
  last = now;
  if (now - since < 1000) return;
  const fps = (gaps.length * 1000) / (now - since);
  const fastest = snap(1000 / [...gaps].sort((a, b) => a - b)[Math.floor(gaps.length * 0.1)]);
  gaps = []; since = now;
  step(fps, fastest);
};
addEventListener('resize', () => { ceiling = Infinity; });
// The Controls sheet narrows the stage while it is open: clamp the size to the room left.
new MutationObserver(() => { if (size && stage.hasAttribute('data-adaptive')) requestAnimationFrame(() => apply(size)); })
  .observe(document.documentElement, { attributes: true, attributeFilter: ['data-sheet-open'] });
const sync = () => {
  cancelAnimationFrame(raf);
  last = since = 0; gaps = []; target = 0; size = 0; ceiling = Infinity; calm = 0; shrinks = 0; probing = false; probeIn = 15;
  if (stage.hasAttribute('data-adaptive')) raf = requestAnimationFrame(tick);
  else stage.style.removeProperty('--fit-d');
};
new MutationObserver(sync).observe(stage, { attributes: true, attributeFilter: ['data-adaptive'] });
sync();

// GPU memory budget (the "GPU memory budget" slider in Controls sets --budget, in MB, on the stage).
// When the GPU runs out of tile memory, wedges silently stop drawing while the frame rate holds, and no web API
// reports it. So the script predicts the memory instead and caps the eyepiece so the estimate stays in budget.
// The model is fitted to Chromium memory dumps (cc/tile_memory at 1400×900, 2×: 65 MB at 12 mirrors / 1 layer,
// 145 MB at 24/4, 180 MB at 32/4): a fixed cost for the page, plus bytes per wedge and per extra fragment layer
// that grow with the eyepiece's area and the pixel density squared.
const PAGE = 40e6 / (1400 * 900 * 4); // bytes per CSS px² of window at 1×
const WEDGE = 4.6; // bytes per wedge, per r² at 1×
const GLASS = 1.7; // bytes per extra fragment layer in a wedge, per r² at 1×
const scope = stage.querySelector('.scope');
const estimate = (d = scope.offsetWidth) => {
  const n = scope.children.length;
  const g = scope.firstElementChild?.children.length ?? 1;
  const dpr2 = devicePixelRatio ** 2;
  return dpr2 * (innerWidth * innerHeight * PAGE + (d / 2) ** 2 * n * (WEDGE + GLASS * (g - 1)));
};
const fitBudget = () => {
  const mb = parseFloat(getComputedStyle(stage).getPropertyValue('--budget')) || 150;
  const n = scope.children.length;
  const g = scope.firstElementChild?.children.length ?? 1;
  const dpr2 = devicePixelRatio ** 2;
  const room = mb * 1e6 / dpr2 - innerWidth * innerHeight * PAGE;
  const r = Math.sqrt(Math.max(0, room) / (n * (WEDGE + GLASS * (g - 1))));
  const cap = `${Math.max(120, Math.floor(2 * r))}px`;
  // Only write when it changes: this observer also watches the stage's style attribute
  if (stage.style.getPropertyValue('--cap-d') !== cap) stage.style.setProperty('--cap-d', cap);
  stage.dataset.memory = Math.round(estimate() / 1e6); // what the current size is estimated to need, for checking
};
new MutationObserver(fitBudget).observe(scope, { childList: true, subtree: true }); // mirrors or glass added
new MutationObserver(fitBudget).observe(stage, { attributes: true, attributeFilter: ['style'] }); // slider moved
addEventListener('resize', fitBudget);
matchMedia(`(resolution: ${devicePixelRatio}dppx)`).addEventListener('change', fitBudget); // moved to another screen
fitBudget();

What ships: tokens and lib snippets inlined, lowered for the browser targets (minified in the real page).

@property --c-ink {
  syntax: "<color>";
  inherits: true;
  initial-value: #14121f;
}

@property --c-paper {
  syntax: "<color>";
  inherits: true;
  initial-value: #f4f1ff;
}

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

@property --c-car-wall {
  syntax: "<color>";
  inherits: true;
  initial-value: #e9e6dc;
}

@property --c-seat {
  syntax: "<color>";
  inherits: true;
  initial-value: #c9c4b4;
}

@property --c-line {
  syntax: "<color>";
  inherits: true;
  initial-value: #ff9500;
}

@property --c-tunnel {
  syntax: "<color>";
  inherits: true;
  initial-value: #05050a;
}

@property --c-lamp {
  syntax: "<color>";
  inherits: true;
  initial-value: #ffe9b0;
}

@property --c-platform {
  syntax: "<color>";
  inherits: true;
  initial-value: #dfe9ff;
}

@property --a-spin {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@layer tokens {
  :root {
    color-scheme: light dark;
    --c-ink: light-dark(#14121f, #f4f1ff);
    --c-paper: light-dark(#f4f1ff, #0b0a14);
    --c-accent: light-dark(#6d4aff, #9d85ff);
    --c-car-wall: light-dark(#e9e6dc, #1a1826);
    --c-seat: light-dark(#c9c4b4, #2a2638);
    --c-line: light-dark(#ff9500, #ff9f1a);
    --c-tunnel: #05050a;
    --c-lamp: #ffe9b0;
    --c-platform: light-dark(#dfe9ff, #9fb4e8);
    --t-fast: .15s;
    --t-base: .4s;
    --t-slow: 1.2s;
    --t-drift: 24s;
    --t-rush: 1.6s;
    --t-swing: 3.6s;
    --ease-out: cubic-bezier(.22, 1, .36, 1);
    --ease-spring: linear(0, .22 2.1%, .73 6.5%, 1.04 10.7%, 1.09 13%, 1.02 20%, .99 27%, 1);
  }
}

@layer reset {
  *, :before, :after {
    box-sizing: border-box;
    margin: 0;
  }

  html {
    block-size: 100%;
  }
}

@layer base {
  body {
    background: var(--c-paper);
    min-block-size: 100dvh;
    color: var(--c-ink);
    font: 1rem / 1.5 system-ui, sans-serif;
    overflow: clip;
  }

  .stage {
    contain: strict;
    position: fixed;
    inset: 0;
    overflow: clip;
  }
}

@layer scene {
  .stage {
    --budget: 150MB;
    --d: min(var(--fit-d, min(84cqmin, 42rem)), var(--cap-d, 100cqmin));
    --r: calc(var(--d) / 2);
    --rim: max(10px, 1.8cqmin);
    --ruby: light-dark(#e8003dcc, #ff2d55);
    --amber: light-dark(#ff9d00cc, #ffb000);
    --teal: light-dark(#00a891cc, #00d1b2);
    --cobalt: light-dark(#1f5bffbb, #3d7bff);
    --violet: light-dark(#9a3dffbb, #b366ff);
    --lime: light-dark(#8fd400cc, #c4ff3d);
    --field: light-dark(#f7f2e6, #07060c);
    --blend: screen;
    background: radial-gradient(circle, light-dark(#0000, #2a1c4a66) 0 calc(var(--r) + var(--rim)), #0000 70%) content-box no-repeat,
      light-dark(#e6dfd0, #0d0b14);
    place-items: center;
    display: grid;
    container-type: size;
  }

  @media (width >= 48rem) {
    :root[data-sheet-open] .stage {
      padding-inline-end: calc(min(22rem, 100vw - 1.5rem) + 1.5rem);
    }
  }

  @media (prefers-color-scheme: light) {
    .stage {
      --blend: multiply;
    }
  }

  .tube {
    padding: var(--rim);
    border-radius: 50%;
    position: relative;
    box-shadow: 0 2cqmin 6cqmin #0008, 0 0 0 1px #0006;
  }

  .tube:before {
    content: "";
    animation: cell var(--turn, 80s) linear infinite;
    background: conic-gradient(from 40deg, #7a5a24, #f0d48c, #8c6a2c, #5e4419, #e2bf6c, #7a5a24);
    border-radius: 50%;
    position: absolute;
    inset: 0;
  }

  .tube:after {
    content: "";
    inset: var(--rim);
    box-shadow: inset 0 0 calc(var(--r) * .18) #000a, inset 0 0 0 1px #0008;
    background: radial-gradient(circle at 32% 26%, #fff4, #fff0 28%);
    border-radius: 50%;
    position: absolute;
  }

  .scope {
    --n: 12;
    inline-size: var(--d);
    aspect-ratio: 1;
    background: radial-gradient(circle, light-dark(#fffdf6, #1a1430), var(--field) 75%);
    border-radius: 50%;
    position: relative;
    overflow: clip;
  }

  .scope b {
    --a: calc(360deg / var(--n));
    --k: 0;
    inline-size: var(--r);
    block-size: calc(var(--r) * tan(var(--a)));
    transform-origin: 0 0;
    transform: rotate(calc(var(--k) * 2 * var(--a)));
    clip-path: polygon(0 0, 100% -1%, 100% 101%);
    position: absolute;
    inset: 50% auto auto 50%;
  }

  .scope b:nth-child(2n) {
    transform: rotate(calc((var(--k) * 2 + 2) * var(--a))) scaleY(-1);
  }

  .scope b:nth-child(n+3) {
    --k: 1;
  }

  .scope b:nth-child(n+5) {
    --k: 2;
  }

  .scope b:nth-child(n+7) {
    --k: 3;
  }

  .scope b:nth-child(n+9) {
    --k: 4;
  }

  .scope b:nth-child(n+11) {
    --k: 5;
  }

  .scope b[style] {
    --k: round(down, (var(--i) - 1) / 2);
  }

  @supports (order: sibling-index()) {
    .scope b {
      --n: sibling-count();
      --k: round(down, (sibling-index() - 1) / 2);
    }
  }

  .scope i, .scope i:before, .scope i:after {
    inset: calc(var(--r) * -.5) auto auto calc(var(--r) * -.18);
    inline-size: calc(var(--r) * 1.45);
    aspect-ratio: 1;
    animation: cell var(--turn, 80s) linear infinite, wander-a 23s ease-in-out infinite alternate;
    position: absolute;
  }

  .scope i {
    transform-origin: 52% 48%;
    background: radial-gradient(circle at 31% 35%, #fffb 0 0.9%, #fff0 1.3%),
      radial-gradient(circle at 33% 37%, var(--ruby) 0 6%, #0000 6.4%),
      radial-gradient(circle at 63% 61%, #fff9 0 0.6%, #fff0 1%),
      radial-gradient(circle at 64% 62%, var(--teal) 0 4%, #0000 4.4%),
      radial-gradient(circle at 47% 74%, var(--amber) 0 3.2%, #0000 3.6%),
      radial-gradient(circle at 71% 29%, var(--lime) 0 2.2%, #0000 2.6%),
      radial-gradient(circle at 52% 50%, #0000 0 9%, var(--amber) 9.3% 9.9%, #0000 10.2%),
      conic-gradient(from 200deg at 56% 44%, var(--cobalt) 0 16deg, #0000 16.6deg 150deg, var(--violet) 150.6deg 166deg, #0000 166.6deg),
      conic-gradient(from 12deg at 40% 58%, var(--amber) 0 20deg, #0000 20.6deg 190deg, var(--teal) 190.6deg 200deg, #0000 200.6deg);
    rotate: 35deg;
  }

  .scope i + i {
    rotate: calc(35deg + var(--i) * 137deg);
    background: none;
  }

  .scope i + i:before {
    display: none;
  }

  .scope i:before {
    content: "";
    transform-origin: 44% 58%;
    inline-size: auto;
    mix-blend-mode: var(--blend);
    background: radial-gradient(circle at 38% 52%, var(--violet) 0 5%, #0000 5.4%),
      radial-gradient(circle at 60% 40%, var(--ruby) 0 2.6%, #0000 3%),
      radial-gradient(circle at 55% 66%, var(--lime) 0 3.6%, #0000 4%),
      radial-gradient(ellipse 9% 3% at 44% 34%, var(--cobalt) 0 98%, #0000),
      conic-gradient(from 90deg at 50% 50%, #0000 0 30deg, var(--teal) 30.6deg 44deg, #0000 44.6deg 210deg, var(--ruby) 210.6deg 222deg, #0000 222.6deg),
      repeating-conic-gradient(from 0deg at 62% 54%, color-mix(in oklab, var(--amber) 45%, #0000) 0 2deg, #0000 2.8deg 24deg);
    animation-name: tumble, wander-b;
    animation-duration: 47s, 13s;
    animation-timing-function: cubic-bezier(.7, 0, .3, 1), ease-in-out;
    inset: -12%;
    rotate: -80deg;
  }

  .scope i:after {
    content: "";
    transform-origin: 66.7% 41.7%;
    background: radial-gradient(circle calc(var(--r) * .029) at 41.7% 29.2%, var(--amber) 0 80%, #0000),
      radial-gradient(circle calc(var(--r) * .022) at 54.2% 37.5%, var(--teal) 0 75%, #0000),
      radial-gradient(circle calc(var(--r) * .033) at 64.6% 72.9%, var(--ruby) 0 82%, #0000),
      radial-gradient(circle calc(var(--r) * .0291) at 20.8% 77.1%, var(--cobalt) 0 78%, #0000),
      conic-gradient(from 300deg at 83.3% 50.0%, var(--violet) 0 14deg, #0000 14.6deg);
    inline-size: auto;
    animation-name: rattle, wander-c;
    animation-duration: 31s, 19s;
    animation-timing-function: cubic-bezier(.8, 0, .2, 1), ease-in-out;
    inset: 20.24%;
    rotate: 140deg;
  }

  @keyframes cell {
    to {
      transform: rotate(1turn);
    }
  }

  @keyframes tumble {
    9% {
      transform: rotate(-30deg) translate(4%, -2%);
    }

    17% {
      transform: rotate(-45deg) translate(1%, 3%);
    }

    26% {
      transform: rotate(-85deg) translate(-4%, 2%);
    }

    34% {
      transform: rotate(-100deg) translate(-2%, -3%);
    }

    45% {
      transform: rotate(-160deg) translate(3%, -4%);
    }

    52% {
      transform: rotate(-172deg) translate(5%, 1%);
    }

    63% {
      transform: rotate(-230deg) translate(-1%, 4%);
    }

    71% {
      transform: rotate(-250deg) translate(-5%);
    }

    82% {
      transform: rotate(-305deg) translate(-2%, -4%);
    }

    90% {
      transform: rotate(-325deg) translate(2%, -1%);
    }

    100% {
      transform: rotate(-360deg);
    }
  }

  @keyframes rattle {
    12% {
      transform: rotate(45deg) translate(-6.2%, 6.2%);
    }

    20% {
      transform: rotate(60deg) translate(4.2%, 10.4%);
    }

    33% {
      transform: rotate(150deg) translate(8.3%, -2.1%);
    }

    41% {
      transform: rotate(170deg) translate(0, -8.3%);
    }

    55% {
      transform: rotate(260deg) translate(-8.3%, -4.2%);
    }

    62% {
      transform: rotate(280deg) translate(-4.2%, 6.2%);
    }

    76% {
      transform: rotate(310deg) translate(6.2%, 8.3%);
    }

    84% {
      transform: rotate(330deg) translate(8.3%, -4.2%);
    }

    100% {
      transform: rotate(360deg);
    }
  }

  @keyframes wander-a {
    0% {
      translate: 0;
    }

    21% {
      translate: 1.5% -1%;
    }

    48% {
      translate: -1% -2%;
    }

    77% {
      translate: -2% 1.5%;
    }

    100% {
      translate: 1% 2%;
    }
  }

  @keyframes wander-b {
    0% {
      translate: 0;
    }

    17% {
      translate: -4% 2%;
    }

    39% {
      translate: 3% 5%;
    }

    58% {
      translate: 5% -2%;
    }

    83% {
      translate: -2% -5%;
    }

    100% {
      translate: 2% -1%;
    }
  }

  @keyframes wander-c {
    0% {
      translate: 0;
    }

    13% {
      translate: 10.4% 6.2%;
    }

    36% {
      translate: -6.2% 12.5%;
    }

    64% {
      translate: -12.5% -4.2%;
    }

    88% {
      translate: 4.2% -10.4%;
    }

    100% {
      translate: 8.3% 2.1%;
    }
  }

  @supports (rotate: random(--k element-shared, 0deg, 1turn)) {
    .stage {
      --turn: random(--turn element-shared, 60s, 100s, 1s);
    }

    .scope i {
      rotate: random(--cell element-shared, 0deg, 1turn);
      animation-delay: random(--cell-t element-shared, -100s, 0s), random(--cell-w element-shared, -23s, 0s);
    }

    .scope i:before {
      rotate: random(--tumble element-shared, 0deg, 1turn);
      animation-delay: random(--tumble-t element-shared, -47s, 0s), random(--tumble-w element-shared, -13s, 0s);
    }

    .scope i:after {
      rotate: random(--rattle element-shared, 0deg, 1turn);
      animation-delay: random(--rattle-t element-shared, -31s, 0s), random(--rattle-w element-shared, -19s, 0s);
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .stage *, .stage :before, .stage :after {
      animation: none !important;
    }
  }
}

@layer utilities;