This is the whole build of Eclipse toggle, in the order it was written. Each stage below is a live page: tap its switch, and from stage 2 on, reach it with Tab and press Space. The code under each heading is only what that stage adds; the finished source is at the end.
The stages share one saved theme. From stage 2 on, flipping one switch flips the others too: that is the
storage event from stage 2 at work, because every stage runs on the same site.
Three files do the work from the start: index.html for the page and the switch,
style.css for the two themes and the drawing, and app.js for the behaviour. From stage 3
there is a fourth, spring.js.
1 · A page in two themes
The switch is not a small control on a big page; the page is part of the effect. So the first stage is a
whole page: a giant greeting, one line of text and the switch, all inside a 432 × 768 frame that
app.js scales to fit the screen. The switch is a plain <button> for now. Everything
inside it is drawn with <span>s and hidden from assistive technology; only the label is read.
Each theme has its own words, marked for-day and for-night.
<h2 class="greeting">
<span class="good">Good</span>
<span class="word"><span class="for-day">morning.</span><span class="for-night">night.</span></span>
</h2>
<p class="lede">
<span class="for-day">Bright, warm and wide awake.</span>
<span class="for-night">Low light, easy on the eyes.</span>
</p>
<div class="switch-wrap">
<span class="floor" aria-hidden="true"></span> <!-- the glow the switch throws on the page -->
<!-- THE COMPONENT: for now a plain button. Everything inside except the label is drawn and aria-hidden. -->
<button class="eclipse" type="button">
<span class="sr-only">Switch the theme</span>
<span class="track" aria-hidden="true">
<span class="sky-day n-out"></span>
<span class="sky-night n-in"></span>
<span class="haze n-out"></span> <!-- sun haze by day -->
<span class="ribbon n-in"></span> <!-- a strand of aurora by night -->
</span>
<span class="rim n-in" aria-hidden="true"></span> <!-- the neon rim light, at night -->
<span class="knob" aria-hidden="true">
<span class="lens lens-day n-out"></span>
<span class="lens lens-night n-in"></span>
<span class="disc"> <!-- the sun; the moon slides across inside it -->
<span class="moon"></span>
</span>
<span class="gloss"></span> <!-- the lens's highlight, over everything -->
</span>
</button>
</div>
Every colour on the page is a variable, and there are two sets of them: one on
[data-theme="dark"], one on [data-theme="light"]. The attribute sits on
<html>, so every element inherits the set that is active. Gradients are variables too: the
greeting's word is filled with --word through background-clip: text.
/* night: near-black with living aurora */
[data-theme="dark"] {
color-scheme: dark;
--bg: radial-gradient(120% 60% at 50% 0%, #140A2A 0%, #05030A 62%);
--base: #05030A;
--ink: #F6F1FF; --ink-2: rgba(226, 214, 255, .62);
--em: linear-gradient(90deg, #00F0C8, #9C7BFF 55%, #FF2BD6);
--good: linear-gradient(180deg, #FFFFFF, #FFFFFF);
--word: linear-gradient(180deg, #F2FFFC 8%, #9EF7E6 34%, #A58BFF 62%, #FF4FDF 96%);
--word-fx: drop-shadow(0 0 18px rgba(123, 47, 255, .55));
--chip: #00F0C8; --chip-ink: #05030A;
--floor: rgba(123, 47, 255, .55);
}
/* day: a luminous dawn */
[data-theme="light"] {
color-scheme: light;
--bg: linear-gradient(180deg, #FFC39B 0%, #FFA88C 30%, #FF8E8E 52%, #E59ACB 76%, #C3A3FF 100%);
--base: #FFB38A;
--ink: #2A0A2C; --ink-2: rgba(42, 10, 44, .64);
--em: linear-gradient(90deg, #B0125F, #7A1FA8);
--good: linear-gradient(180deg, #2A0A2C, #2A0A2C);
--word: linear-gradient(180deg, #6A18A8 6%, #C4126C 52%, #FF4A1C 98%);
--word-fx: drop-shadow(0 10px 22px rgba(255, 240, 210, .55));
--chip: #2A0A2C; --chip-ink: #FFD6B8;
--floor: rgba(255, 244, 214, .85);
}
For now the theme also draws the switch. The night parts (.n-in) are hidden by day, the day parts
(.n-out) at night; the knob sits 120 px further right at night, and the moon sits right over the sun.
The same attribute picks which words show.
/* the line for the other theme is hidden. Through a token, so the nearest [data-theme] wins: the new-theme copy that
the reveal lays over the page shows its own line, not neither */
[data-theme="light"] { --show-day: inline; --show-night: none; }
[data-theme="dark"] { --show-day: none; --show-night: inline; }
.for-day { display: var(--show-day); } .for-night { display: var(--show-night); }
/* for now the theme draws the switch: the night parts show at night, the day parts by day */
[data-theme="light"] .n-in, [data-theme="dark"] .n-out { opacity: 0; }
[data-theme="dark"] .knob { transform: translateX(120px); } /* the knob's travel: 240 − 2 × 10 − 100 */
.moon { transform: translateX(62px); } /* off the sun by day … */
[data-theme="dark"] .moon { transform: none; } /* … right over it at night */
The script only fits the frame and flips one attribute.
const html = document.documentElement;
const toggle = document.querySelector('.eclipse');
// fit the 432 × 768 composition to the screen
function fit() { html.style.setProperty('--k', Math.min(innerWidth / 432, innerHeight / 768).toFixed(4)); }
fit();
addEventListener('resize', fit);
// the theme: one attribute on <html>; the CSS does everything else
toggle.addEventListener('click', () => {
html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
});
2 · A real switch that remembers
A theme control is an on/off setting, so the button becomes a role="switch" with
aria-checked, and its label becomes the thing it turns on: Dark mode. A screen reader now
announces it as a switch and says whether it is on. It stays a <button>, so a click, a tap,
Enter and Space all work without extra code. A visible :focus-visible ring shows where the keyboard is.
<button class="eclipse" type="button" role="switch" aria-checked="false">
<span class="sr-only">Dark mode</span>
.eclipse:focus-visible { outline: 3px solid var(--focus); outline-offset: 7px; }
The choice is kept in localStorage under the key theme. Both reading and writing are
wrapped in try, because storage can be blocked; then the choice simply lasts for the visit. Two
variables keep two different facts: theme is what the page shows, dark is what the
switch says. In this stage they always agree; from stage 4 on they will not, for a moment. The
theme-color meta tag follows the page, so the browser's own bar changes colour with it.
// the theme: saved choice, else the system's
const KEY = 'theme';
function saved() {
try { const t = localStorage.getItem(KEY); return t === 'light' || t === 'dark' ? t : null; } catch (e) { return null; }
}
function save(t) { try { localStorage.setItem(KEY, t); } catch (e) { /* private mode: the choice lasts this visit */ } }
const META = { light: '#FFB38A', dark: '#05030A' };
let theme = html.dataset.theme === 'dark' ? 'dark' : 'light'; // the theme the page is in
let dark = theme === 'dark'; // what the switch says
const meta = () => { document.querySelector('meta[name="theme-color"]').content = META[theme]; };
meta();
Everything that changes the theme goes through setDark(). A click saves the choice. A change of the
system's prefers-color-scheme is followed only while the visitor has not chosen. And when another tab
of the same site saves a theme, the storage event brings this page along without saving again.
function setDark(on, { remember = true } = {}) {
if (on === dark) return;
dark = on;
toggle.setAttribute('aria-checked', String(on));
if (remember) save(on ? 'dark' : 'light');
theme = on ? 'dark' : 'light'; // for now the page follows at once
html.dataset.theme = theme;
meta();
}
// input: the switch is a real <button role="switch">, so click covers mouse, touch, Enter and Space
toggle.addEventListener('click', () => setDark(!dark));
// until the visitor chooses, follow the system; and keep other open tabs in step
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (!saved()) setDark(e.matches, { remember: false }); });
addEventListener('storage', (e) => { if (e.key === KEY && (e.newValue === 'dark' || e.newValue === 'light')) setDark(e.newValue === 'dark', { remember: false }); });
The script at the bottom of the page runs after the first paint. A visitor who chose dark would see a light
page flash first, so a few lines in <head> set data-theme before anything is drawn:
the saved choice, or else the system's.
<!-- before the first paint: the theme — the saved choice, else the system's — so the page never flashes the
wrong colours -->
<script>
(() => {
let theme = null;
try { theme = localStorage.getItem('theme'); } catch (e) { /* storage blocked: follow the system */ }
if (theme !== 'light' && theme !== 'dark') theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
})();
</script>
3 · Springs: the knob flies
Every motion in this component comes from one small class. A spring has a value, a target and a velocity; each step it accelerates towards the target and a damper slows it down. Low damping overshoots, high damping creeps in. Unlike a CSS transition, a spring can change its target in the middle of a move without a jump, which is what lets a second tap turn the switch round half-way.
// A spring, written from scratch: a mass on a spring with a damper.
// acceleration = stiffness × (target − value) − damping × velocity
// It is stepped in small fixed slices (1/240 s), so it moves the same on a 60 Hz and a 120 Hz screen.
// Changing the target never moves the value: an interrupted animation just bends towards the new target.
class Spring {
constructor(value = 0, { stiffness = 170, damping = 26 } = {}) {
this.value = value;
this.target = value;
this.velocity = 0;
this.stiffness = stiffness;
this.damping = damping;
}
set(options) { Object.assign(this, options); return this; }
step(dt) {
const slice = 1 / 240;
for (let t = dt; t > 0; t -= slice) {
const h = Math.min(slice, t);
const acceleration = this.stiffness * (this.target - this.value) - this.damping * this.velocity;
this.velocity += acceleration * h;
this.value += this.velocity * h;
}
return this.value;
}
resting(epsilon = 0.01) {
return Math.abs(this.target - this.value) < epsilon && Math.abs(this.velocity) < epsilon * 10;
}
}
The CSS rules that drew the switch from the theme are gone; the switch now draws itself from five springs.
knob runs from 0 to 1 with a damping ratio of 0.54, so it overshoots once and settles.
night fades the night parts in more slowly. moon slides the moon over the sun.
squash stretches the knob along its travel as it gains speed, and press squashes it under
the finger.
// the springs (stiffness, damping; ζ = damping / 2√stiffness)
const spring = (stiffness, damping) => ({ stiffness, damping });
const TRAVEL = 120; // the knob's travel, px
const D = 62; // the moon's offset when it is off the sun (sun: 56 px)
const knob = new Spring(dark ? 1 : 0, spring(190, 15)); // ζ 0.54: one lively overshoot
const squash = new Spring(0, spring(320, 22)); // stretch along the travel
const moon = new Spring(dark ? 0 : D, spring(260, 28)); // the eclipse itself
const night = new Spring(dark ? 1 : 0, spring(80, 17)); // the track's sky, the lens, the rim light
const press = new Spring(0, spring(600, 30)); // squash under the finger
function setDark(on, { remember = true } = {}) {
if (on === dark) return;
dark = on;
toggle.setAttribute('aria-checked', String(on));
if (remember) save(on ? 'dark' : 'light');
knob.target = on ? 1 : 0;
night.target = on ? 1 : 0;
moon.target = on ? 0 : D;
theme = on ? 'dark' : 'light'; // the page still follows at once
html.dataset.theme = theme;
meta();
wake();
}
Drawing is one function that turns spring values into transforms and opacities. It takes a set of elements,
v, rather than reaching for them itself: in stage 5 there will be two copies of the switch on screen,
and both are drawn from the same springs.
// drawing the switch from its springs
function renderSwitch(v) {
const x = knob.value * TRAVEL;
const n = clamp(night.value, 0, 1);
const s = press.value, q = squash.value;
v.knob.style.transform = `translateX(${x.toFixed(2)}px) scale(${(1 + 0.07 * s + q).toFixed(3)}, ${(1 - 0.06 * s - 0.6 * q).toFixed(3)})`;
for (const e of v.nIn) e.style.opacity = n.toFixed(3);
for (const e of v.nOut) e.style.opacity = (1 - n).toFixed(3);
v.moon.style.transform = `translateX(${moon.value.toFixed(2)}px)`;
}
The press comes from pointerdown and pointerup, and from keydown and
keyup for Space and Enter. The loop runs only while something moves. Its time step is capped at
32 ms, so a frame after a stall does not throw the springs across the screen.
// the loop: it runs only while something moves
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
const all = [knob, squash, moon, night, press];
function frame(now) {
const dt = Math.min(0.032, Math.max(0, (now - last) / 1000)); last = now;
squash.target = clamp(Math.abs(knob.velocity) * 0.03, 0, 0.14);
all.forEach((s) => s.step(dt));
draw();
const still = all.every((s) => s.resting(0.002));
if (!still) requestAnimationFrame(frame); else running = false;
}
function draw() {
for (const v of views) renderSwitch(v);
}
4 · The eclipse
The knob gets its sky: a halo for the moon, a corona that flares at totality, twelve rays, a face for the moon with three craters, and the "diamond ring" — a bead of light with a thin flare through it. The track gets stars.
<span class="halo"></span> <!-- the moon's glow, at night -->
<span class="corona"></span> <!-- the flare of the corona at totality -->
<span class="rays"></span> <!-- twelve rays, filled by app.js -->
<span class="disc"> <!-- the sun; the moon slides across inside it -->
<span class="moon"><span class="moonlit"><i></i><i></i><i></i></span></span>
</span>
<span class="gloss"></span> <!-- the lens's highlight, over everything -->
<span class="bead"></span> <!-- the "diamond ring": the last point of sunlight -->
<span class="streak"></span> <!-- its anamorphic flare -->
The rays and the stars are made by the script. The stars are placed left of where the moon comes to rest, so they are never hidden behind it.
// twelve rays around the sun
const rayHost = toggle.querySelector('.rays');
for (let i = 0; i < 12; i++) rayHost.append(document.createElement('i'));
// stars in the track, all left of where the moon comes to rest: [x, y, size, sparkle?]
const STARS = [[26, 30, 3], [48, 70, 2.4], [66, 22, 2.2], [88, 50, 12, 1], [34, 90, 2], [104, 18, 2], [112, 88, 2.6], [20, 60, 1.8], [72, 100, 1.6]];
toggle.querySelector('.stars').innerHTML = STARS.map(([x, y, s, sp]) =>
`<i class="${sp ? 'sparkle' : ''}" style="left:${x - s / 2}px;top:${y - s / 2}px;width:${s}px;height:${s}px"></i>`).join('');
Three more springs. rays pulls the rays in under the disc and grows them back. lit
turns the moon from a dark silhouette into a lit face. flare rests at 0 and is only ever kicked: its
damping is exactly 2√stiffness, critical, so a kick gives one clean pulse with no bounce.
const moon = new Spring(dark ? 0 : D, spring(260, 28)); // the eclipse itself: totality ≈ 0.25 s
const rays = new Spring(dark ? 0 : 1, spring(200, 18)); // rays pull in (and grow back with a spring)
const lit = new Spring(dark ? 1 : 0, spring(90, 16)); // the moon's face: dark silhouette → lit
const flare = new Spring(0, spring(38, 2 * Math.sqrt(38))); // corona: kicked at totality, a critically damped pulse
The two directions are not mirror images. Going dark, the moon comes in unlit and the rays pull in at once.
Going light, the moon goes dark first, then slides off 80 ms later, then the rays grow back. later()
runs a function after a delay measured on the loop's own clock, and a new tap clears whatever is still waiting.
let flared = true; // the impact has fired for this switch
let pendingWave = null; // the theme waiting for the impact
function setDark(on, { remember = true } = {}) {
if (on === dark) return;
dark = on;
toggle.setAttribute('aria-checked', String(on));
if (remember) save(on ? 'dark' : 'light');
knob.target = on ? 1 : 0;
night.target = on ? 1 : 0;
queue.length = 0; flared = false;
if (on) { // the moon comes in dark; it lights up at totality (frame())
moon.target = 0; rays.target = 0; lit.target = 0;
} else { // the moon goes dark first, then slides off, then the rays grow back
lit.target = 0;
later(0.08, () => { moon.target = D; });
later(0.16, () => { rays.target = 1; });
}
pendingWave = on ? 'dark' : 'light';
later(0.6, wave); // normally the eclipse's impact fires first; this is the safety net
wake();
}
The page no longer changes at the tap. It waits for the impact: the moment the moon covers the sun
(within 3 px), or the moment the first light comes back (4 px off). That frame lights the moon, kicks the corona
and calls wave(). If the impact never comes, the 0.6 s timer in setDark() calls it
anyway.
const queue = [];
let clock = 0;
function later(s, fn) { queue.push({ at: clock + s, fn }); }
// in frame(), after the springs have stepped
// totality (on) or the first light coming back (off): the corona flares, the page takes the new theme
if (!flared && dark && moon.value < 3) { flared = true; lit.target = 1; flare.velocity += 19; wave(); }
if (!flared && !dark && moon.value > 4) { flared = true; flare.velocity += 13; wave(); }
// the impact: for now the page just takes the new theme
function wave() {
if (!pendingWave) return;
const to = pendingWave; pendingWave = null;
theme = to;
html.dataset.theme = theme;
meta();
}
The drawing follows the springs: the rays turn as they pull in, the sun's glow fades as the moon covers it, and
the corona, the bead and the flare all scale with flare.
v.rays.forEach((r, i) => { // pulled in under the disc, turning as they go
r.style.transform = `rotate(${(i * 30 + (1 - ry) * 50).toFixed(2)}deg) translateY(${(-(24 + 36 * ry)).toFixed(2)}px) scaleY(${(0.2 + 0.8 * ry).toFixed(3)})`;
r.style.opacity = clamp(ry * 1.6, 0, 1).toFixed(3);
});
v.disc.style.boxShadow = `0 0 ${(18 + 10 * (1 - cover)).toFixed(1)}px rgba(var(--sun), ${(0.9 * (1 - cover)).toFixed(3)}), 0 0 60px rgba(var(--sun), ${(0.45 * (1 - cover)).toFixed(3)})`;
v.moon.style.transform = `translateX(${moon.value.toFixed(2)}px)`;
v.moonlit.style.opacity = l.toFixed(3);
v.halo.style.opacity = (0.95 * l * n).toFixed(3);
v.halo.style.transform = `translate(-50%, -50%) scale(${(0.7 + 0.3 * l + 0.04 * Math.sin(t * 1.4)).toFixed(3)})`;
v.corona.style.opacity = clamp(f, 0, 1).toFixed(3);
v.corona.style.transform = `translate(-50%, -50%) scale(${(0.45 + 0.85 * f).toFixed(3)}) rotate(${(f * 30).toFixed(2)}deg)`;
v.bead.style.opacity = clamp(f * 1.5, 0, 1).toFixed(3);
v.bead.style.transform = `translate(-50%, -50%) scale(${(0.6 + 0.8 * f).toFixed(3)})`;
v.streak.style.opacity = clamp(f * 1.3, 0, 1).toFixed(3);
v.streak.style.transform = `translate(-50%, -50%) scaleX(${(0.3 + 0.9 * f).toFixed(3)})`;
5 · The shockwave, and the rolling letters
At the impact, the new theme should spread out of the switch. A page cannot be in two themes at once, but two
copies of it can. The script clones the whole stage, gives the copy's wrapper the other data-theme,
and lays it on top. The copy is aria-hidden and inert, so nothing in it can be read or
focused.
function buildCopy(to) {
const copy = stage.cloneNode(true);
copy.classList.add('is-copy');
copy.dataset.theme = to;
const wrap = document.createElement('div');
wrap.className = 'reveal';
wrap.dataset.theme = to;
wrap.setAttribute('aria-hidden', 'true');
wrap.inert = true;
wrap.append(copy);
return wrap;
}
The copy is clipped to a circle whose radius is a spring, reveal, centred where the knob is
going. The radius follows the square of the spring, so the wave blooms slowly at the switch and then sweeps out.
When it covers the far corner of the screen, the page itself takes the new theme and the copy is removed in the
same frame, so nothing flickers. A tap during the wave turns it round: it shrinks back into the switch.
let cx = 0, cy = 0, far = 1; // centre and reach in viewport px
function startReveal(to) {
if (layer) { reveal.target = to === layerTheme ? 1 : 0; return; } // mid-way: grow on, or shrink back
if (to === theme) return;
if (spare && spare.dataset.theme !== to) { spare.remove(); spare = null; }
layer = spare || buildCopy(to); // normally built ahead, while idle
spare = null;
layer.style.opacity = '';
layerTheme = to;
// the centre: where the knob is going (the moon for the night, the sun for the day)
const r = toggle.getBoundingClientRect(), vk = r.width / 240;
cx = r.left + (60 + (to === 'dark' ? TRAVEL : 0)) * vk;
cy = r.top + 60 * vk;
far = Math.max(Math.hypot(cx, cy), Math.hypot(innerWidth - cx, cy), Math.hypot(cx, innerHeight - cy), Math.hypot(innerWidth - cx, innerHeight - cy));
reveal.value = 0; reveal.velocity = 0; reveal.target = 1;
if (!layer.isConnected) stage.after(layer);
views = [parts(stage), parts(layer)];
drawReveal();
}
// the radius follows the spring squared: the wave blooms at the switch, then sweeps out
const radius = () => far * 1.1 * Math.max(0, reveal.value) ** 2;
function drawReveal() {
if (!layer) return;
layer.style.clipPath = `circle(${radius().toFixed(1)}px at ${cx.toFixed(1)}px ${cy.toFixed(1)}px)`;
}
function settleReveal() {
if (!layer) return;
if (reveal.target === 1 && radius() >= far) { // covered: the page takes the new theme
theme = layerTheme;
html.dataset.theme = theme;
meta();
dropLayer();
} else if (reveal.target === 0 && reveal.value < 0.002 && Math.abs(reveal.velocity) < 0.05) {
dropLayer(); // shrunk back into the switch: nothing changed
}
}
function dropLayer() { layer.remove(); layer = null; views = [parts(stage)]; idle(prepare); }
Cloning a whole page takes time, and a clone that becomes visible for the first time can show missing tiles.
So the next copy is built ahead, while the browser is idle, and kept on screen at an opacity of 0.002
(prepare(), in the stage's app.js).
The greeting's word becomes a row of slots, one per letter. Each slot holds the day letter and the night letter,
and the real word is kept for screen readers in a hidden .word-sr.
<span class="word" aria-hidden="true"></span> <!-- letter slots, filled by app.js; they roll on springs -->
<span class="sr-only word-sr">morning.</span>
// the greeting's changing word: one slot per letter, each holding the day letter and the night letter
const DAY_WORD = 'morning', NIGHT_WORD = 'night';
const SLOTS = Math.max(DAY_WORD.length, NIGHT_WORD.length);
const wordHost = document.querySelector('.word');
wordHost.innerHTML = Array.from({ length: SLOTS }, (_, i) =>
`<span class="slot"><span class="face f-day">${DAY_WORD[i] || ''}</span><span class="face f-night">${NIGHT_WORD[i] || ''}</span></span>`).join('')
+ '<span class="slot dot"><span class="face">.</span></span>';
const wordSr = document.querySelector('.word-sr');
wordSr.textContent = (dark ? NIGHT_WORD : DAY_WORD) + '.';
Each letter is a face on a small drum: transform-origin sits 0.3 em behind the letter, so
rotateX rolls it over rather than flipping it flat. Each face paints its own piece of one shared
gradient, shifted by --x, so the word still reads as one gradient.
.word {
display: block; height: 108px; margin-top: -8px; white-space: nowrap; perspective: 520px;
font-family: var(--serif); font-weight: 400; font-style: italic; font-size: 102px; line-height: 108px; letter-spacing: -.01em;
filter: var(--word-fx);
}
.slot { position: relative; display: inline-block; height: 108px; vertical-align: top; }
.face {
position: absolute; left: -.16em; top: -.08em; padding: .08em .16em .24em; transform-origin: 50% 44% -.3em; backface-visibility: hidden;
background: var(--word); background-size: var(--ww, 400px) 100%; background-position: calc(.16em - var(--x, 0px)) 0;
-webkit-background-clip: text; background-clip: text; color: transparent; will-change: transform, opacity;
}
.measure { position: absolute; visibility: hidden; white-space: pre; }
Every slot has its own spring. A slot's width moves from the day letter's width to the night letter's, measured once the fonts have loaded, so the word closes up as "morning" becomes "night".
// ---- the kinetic greeting: every letter sits on a small drum and rolls on its own spring ----
function renderWord(v) {
let x = 0;
v.slots.forEach((slot, i) => {
const p = letters[i].value, c = clamp(p, 0, 1);
const w = wDay[i] + (wNight[i] - wDay[i]) * c;
slot.style.width = w.toFixed(2) + 'px';
slot.style.setProperty('--x', x.toFixed(1) + 'px');
x += w;
const day = v.days[i].style, nite = v.nights[i].style;
const pop = 1 + 0.14 * Math.sin(Math.PI * c); // a small kick as each one turns
day.transform = `rotateX(${(p * 100).toFixed(2)}deg) scale(${pop.toFixed(3)})`;
nite.transform = `rotateX(${((p - 1) * 100).toFixed(2)}deg) scale(${pop.toFixed(3)})`;
day.opacity = clamp(1 - 2.4 * (p - 0.12), 0, 1).toFixed(3); // a short cross-over, so the drum reads cleanly
nite.opacity = clamp(2.4 * (p - 0.42), 0, 1).toFixed(3);
});
v.dot.style.width = wDot.toFixed(2) + 'px';
v.dot.style.setProperty('--x', x.toFixed(1) + 'px');
v.word.style.setProperty('--ww', (x + wDot).toFixed(1) + 'px');
}
The impact now starts the wave and sets the letters rolling, 45 ms apart, left to right.
// the impact: the wave starts, and the letters roll one after another
function wave() {
if (!pendingWave) return;
const to = pendingWave; pendingWave = null;
startReveal(to);
letters.forEach((s, i) => later(LETTER_DELAY + i * LETTER_STAGGER, () => { s.target = to === 'dark' ? 1 : 0; }));
wordSr.textContent = (to === 'dark' ? NIGHT_WORD : DAY_WORD) + '.';
}
6 · The finish
The last stage adds the finish and the care. The wavefront gets its own layer, .fx, laid over both
copies of the page: a glowing band, colour-split rings for one of the looks, and sparks.
<!-- the shockwave: drawn over both themes while the new one spreads (app.js, stage 6) -->
<div class="fx" aria-hidden="true">
<div class="fx-band"></div>
<div class="fx-ring"></div><div class="fx-ring"></div><div class="fx-ring"></div>
<div class="fx-sparks"></div>
</div>
The band is a stack of radial gradients centred on the wave, each a thin ring a few pixels from the edge. Going dark in the aurora look, a white core sits between a pink fringe outside and a teal one inside: a fake RGB split, made of nothing but gradients.
const R = radius();
const X = cx.toFixed(1) + 'px', Y = cy.toFixed(1) + 'px';
const at = (d) => Math.max(0, R + d * vk).toFixed(1) + 'px';
const g = (a, color, b, c) => `radial-gradient(circle at ${X} ${Y}, transparent ${at(a)}, ${color} ${at(b)}, transparent ${at(c)})`;
// …
const layers = LOOK === 'holo'
? [ // a prism band: the spectrum laid across the wavefront, with a white-hot leading edge
g(-2, 'rgba(255,255,255,1)', 1, 4),
`radial-gradient(circle at ${X} ${Y}, transparent ${at(-46)}, rgba(163,107,255,.9) ${at(-38)}, rgba(61,200,255,.9) ${at(-29)}, rgba(61,255,160,.9) ${at(-20)}, rgba(255,211,61,.9) ${at(-12)}, rgba(255,93,168,.95) ${at(-5)}, transparent ${at(-1)})`,
g(-130, `rgba(${c4}, .22)`, -40, -30),
g(3, `rgba(${c4}, .25)`, 10, 70),
]
: [ // aurora: a white-hot core with a colour fringe either side (the fake RGB split), a soft glow band
g(3, `rgba(${c1}, .95)`, 6.5, 11),
g(-2.5, `rgba(${c2}, 1)`, 0.5, 3.5),
g(-10, `rgba(${c3}, .95)`, -5.5, -1),
g(-130, `rgba(${c4}, .38)`, -28, -3),
g(4, `rgba(${c1}, .22)`, 14, 80),
];
band.style.background = layers.join(',');
The sparks ride just ahead of the edge. Each has its own angle and distance, fades in as the wave leaves the switch and out before the screen's edge, and in the aurora look it stretches with the wave's speed.
// sparks: thrown out along the wavefront, running a little ahead of it, streaked by its speed
const stretch = 1 + clamp(speedR * 0.0011 / vk, 0, 3.2);
const growing = reveal.target === 1 ? 1 : 0;
const amp = JELLY ? blobAmp(R) : 0;
for (const p of sparks) {
const edge = R + (JELLY ? amp * blobShape(p.a) : 0);
const rho = edge * p.s + 10 * vk;
const o = growing * clamp((R - 24 * vk) / (70 * vk), 0, 1) * clamp((far * 0.98 - rho) / (170 * vk), 0, 1);
const x = cx + Math.cos(p.a) * rho, y = cy + Math.sin(p.a) * rho;
const sx = LOOK === 'aurora' ? stretch * p.z : p.z, sy = p.z;
const turn = LOOK === 'holo' ? p.a + p.spin + clock * 3 : p.a;
p.el.style.opacity = o.toFixed(3);
p.el.style.transform = o > 0 ? `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${turn.toFixed(3)}rad) scale(${(sx * vk).toFixed(3)}, ${(sy * vk).toFixed(3)})` : 'scale(0)';
}
At the impact the whole scene jolts: one more spring, bump, kicked at the same moment as the corona
and fed into the frame's scale.
const bump = new Spring(0, spring(300, 11)); // the whole scene's jolt at impact
// in frame(), at the impact
if (!flared && dark && moon.value < 3) { flared = true; lit.target = 1; if (!calm) { flare.velocity += 19; bump.velocity += 7; } wave(); }
// in draw()
html.style.setProperty('--bump', (1 + 0.05 * bump.value).toFixed(4));
.frame { position: relative; width: 432px; height: 768px; flex: none; transform: scale(calc(var(--k, 1) * var(--bump, 1))); }
Behind everything, big soft blobs of light drift and breathe, and star specks come out at night. The drift is a pure function of time, so the same moment always draws the same sky.
function renderAmbient(v, t) {
v.blobs.forEach((b, i) => {
const [x, y, rot, dx, dy, per, br] = BLOBS[i];
const w = TAU / per, ph = i * 1.7;
const px = x + dx * Math.sin(w * t + ph), py = y + dy * Math.sin(w * 0.73 * t + ph * 1.3);
const s = 1 + br * Math.sin(w * 1.21 * t + ph * 0.6);
const r = rot + 7 * Math.sin(w * 0.6 * t + ph);
b.style.transform = `translate(${(px - SIZES[i][0] / 2).toFixed(1)}px, ${(py - SIZES[i][1] / 2).toFixed(1)}px) rotate(${r.toFixed(2)}deg) scale(${s.toFixed(3)}, ${(s * (1 + 0.5 * br * Math.sin(w * t))).toFixed(3)})`;
b.style.opacity = `calc(var(--blob-o) * ${(0.8 + 0.2 * Math.sin(w * 0.9 * t + ph * 2)).toFixed(3)})`;
});
With prefers-reduced-motion, every spring is made critically damped, so nothing overshoots; the
corona and the bump are not kicked; the wave becomes a plain cross-fade of the new page; the letters cross-fade
in place; and the sky stands still, so the loop again runs only while something moves.
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
// With reduced motion every spring is critically damped: no overshoot, no flare, no shockwave (a cross-fade).
const spring = (stiffness, damping) => ({ stiffness, damping: calm ? 2 * Math.sqrt(stiffness) : damping });
const reveal = new Spring(0, calm ? spring(160, 0) : spring(46, 11.8)); // the wave (or, calm, a cross-fade)
// in drawReveal()
if (calm) { layer.style.opacity = clamp(reveal.value, 0, 1).toFixed(3); return; }
// the loop
const still = all.every((s) => s.resting(0.002)) && !layer && !queue.length;
if (!calm || !still) requestAnimationFrame(frame); else running = false;
Three looks, one code
The finished file has three looks: aurora, acid and holo. Each is two more token sets, a
night and a day, keyed on a second attribute, data-look. The head script reads it from the address
next to the theme.
const look = new URLSearchParams(location.search).get('look');
html.dataset.look = ['aurora', 'acid', 'holo'].includes(look) ? look : 'aurora';
The token rules now match two attributes at once. They have a second, descendant form because while the wave
runs, <html> carries the look and the copy's wrapper carries the other theme.
/* CANDY ACID — day: tangerine with acid yellow; night: electric ultraviolet into hot pink. A jelly knob. */
:is([data-look="acid"][data-theme="dark"], [data-look="acid"] [data-theme="dark"]) {
color-scheme: dark;
--bg: linear-gradient(180deg, #2A00D8 0%, #3A00FF 26%, #7A00F0 56%, #D012C4 80%, #FF2E9A 100%);
--base: #3A00FF;
--b1: #FF2E9A; --b2: #00E5FF; --b3: #E8FF3A; --b4: #8A00FF; --blob-o: .75;
--ink: #FFFFFF; --ink-2: rgba(255, 255, 255, .74);
--em: linear-gradient(90deg, #E8FF3A, #E8FF3A);
--good: linear-gradient(180deg, #FFFFFF, #FFFFFF);
--word: linear-gradient(180deg, #FFFFFF 20%, #FFE0F2 50%, #E8FF3A 88%);
--word-fx: drop-shadow(0 5px 0 #16005E);
--chip: #E8FF3A; --chip-ink: #16005E;
--pill: #FFFFFF; --pill-edge: rgba(255, 255, 255, 1); --pill-ink: #16005E; --pill-dim: rgba(22, 0, 94, .56);
--pill-icon: linear-gradient(135deg, #FF2E9A, #FF2E9A); --pill-icon-ink: #FFFFFF; --pill-shadow: rgba(22, 0, 94, .55);
--stars: 1; --speck: #FFE3F4; --lockup: none; --ring: #FFFFFF; --focus: #E8FF3A;
--floor: rgba(255, 46, 154, .85);
}
Most of each look is those tokens and a block of part styles under [data-look]. A few things are
behaviour, and app.js reads the look for them: the acid knob is jelly (a softer squash spring, and a
wave with a wobbling edge); the holo track's foil turns with the knob.
const LOOK = html.dataset.look; // set before the first paint (index.html)
const JELLY = LOOK === 'acid';
Make it yours
- Colours are variables: pick a look with
?look=, or copy a token block and change the values, and the page and the greeting follow. The switch's own parts are styled per look under[data-look], and the wave's colours are theRIMandSPARK_COLORSlists inapp.js. - The words are two constants in
app.js,DAY_WORDandNIGHT_WORD; the slots are built for the longer one. - The feel is in the springs: the damping of
knobfor its overshoot,moonfor how fast totality comes,revealfor the speed of the wave, andLETTER_STAGGERfor how the letters follow one another. - The saved theme lives in
localStorageundertheme. Until the visitor chooses, the page followsprefers-color-scheme, the head script prevents a flash of the wrong theme, and other open tabs follow through thestorageevent. - Accessibility is already there: a real
<button role="switch">witharia-checkedand the label "Dark mode", a visible focus ring, Enter and Space, the drawn parts and the page copy hidden from assistive technology, and a screen-reader copy of the greeting that changes with it.prefers-reduced-motionremoves every overshoot, the flare, the jolt and the moving sky, and turns the wave and the letters into cross-fades.
Keep what the switch says apart from what the
page shows. dark changes at the tap and theme changes when the wave has covered the
screen; everything in between — the eclipse, the impact, the wave, a tap that turns it round — lives in the gap
between those two variables.