This is the whole build of the stained theme picker, in the order it was written: the Appearance settings of a made-up writing app, Vigil. Every theme is a pane of glass in a lancet window. Pick one and the sun comes through it: the pane flares, a glint runs across the glass, and its coloured light travels across the room and washes over the page until the whole app has taken on its colour. Each stage below is the page as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.
There is no framework and no build step: one HTML page, one style sheet and four ES modules. The window and the light are two WebGL canvases, one behind the page and one above it. The panes themselves are real buttons laid exactly over the glass, so the picker is an ordinary radio group for the keyboard and for a screen reader.
1 · The picker, without WebGL
Before anything paints, a few lines in the head read the stored theme and set the accent, so a return visit never
flashes the default colour first. Only keys under dv87.v1. survive; older ones are wiped.
// storage: only dv87.v1.* survives (older dv87 keys wiped before first paint). The theme is the page's own, never the OS's.
(function () {
var HEX = ['#E8657F', '#6D8CFF', '#3FC28A', '#F2A93B', '#A77BF0', '#DCD6C8'], i = 1, d = document.documentElement;
try {
for (var k = localStorage.length - 1; k >= 0; k--) { var key = localStorage.key(k); if (key && key.indexOf('dv87') === 0 && key.indexOf('dv87.v1.') !== 0) localStorage.removeItem(key); }
var a = localStorage.getItem('dv87.v1.applied'); if (a !== null && HEX[+a]) i = +a;
var pv = JSON.parse(localStorage.getItem('dv87.v1.preview') || 'null'); if (pv && pv.until > Date.now() && HEX[pv.i]) i = pv.i;
if (localStorage.getItem('dv87.v1.tint') === '0') d.setAttribute('data-tint', 'off');
} catch (e) {}
d.style.setProperty('--acc', HEX[i]);
d.classList.add('js');
})();
The six panes are buttons with role="radio" inside a radiogroup. Each carries its colour
in a custom property and a small name tag that shows on hover and on keyboard focus.
const btns = THEMES.map((t, i) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'pane'; b.setAttribute('role', 'radio');
b.setAttribute('aria-label', `${t.n}, ${t.d}`);
b.style.setProperty('--pc', t.hex);
b.innerHTML = `<span class="tag" aria-hidden="true">${t.n}</span>`;
b.addEventListener('click', () => pick(i, 'pointer'));
panes.appendChild(b);
return b;
});
The arrow keys move through the two columns and three rows, Home and End jump to the ends, and only the checked pane is in the tab order (a roving tabindex), as a radio group should be.
panes.addEventListener('keydown', (e) => {
const i = btns.indexOf(document.activeElement); if (i < 0) return;
const j = { ArrowRight: (i + 1) % 6, ArrowLeft: (i + 5) % 6, ArrowDown: (i + 2) % 6, ArrowUp: (i + 4) % 6, Home: 0, End: 5 }[e.key];
if (j !== undefined) { e.preventDefault(); btns[j].focus(); pick(j, 'key'); }
else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); pick(i, 'key'); }
});
function pick(i, how) {
if (LOCKED || i === st.sel) return;
st.sel = i; st.state = 'picked';
st.offT = shiftFor(i);
setAccent(); syncButtons(); emit();
placeFallback();
}
Nothing changes for good until you apply: Apply saves (here a simulated request, with a busy state, a success tick or an error when you are offline), Preview for a day keeps the pick for 24 hours, and Revert goes back.
el.apply.addEventListener('click', async () => {
if (el.apply.getAttribute('aria-disabled') === 'true' || st.state === 'busy') return;
const i = st.sel, n = THEMES[i].n;
st.state = 'busy'; el.apply.setAttribute('aria-busy', 'true'); el.applyL.textContent = 'Saving…'; el.apply.classList.remove('err'); el.card.classList.remove('err');
say(`Saving ${n}…`);
await new Promise((r) => setTimeout(r, NET));
el.apply.removeAttribute('aria-busy');
if (OFFLINE) {
st.state = 'error'; el.apply.classList.add('err'); el.card.classList.add('err'); el.applyL.textContent = 'Try again';
say('Couldn’t save: you’re offline. Your pick stays on this device until you reconnect.', 'err');
return;
}
st.applied = i; store.set('applied', String(i));
st.preview = null; store.del('preview');
st.state = 'applied'; syncButtons();
say(`${n} is now your theme.`, 'ok');
emit({ applied: true });
});
The window box is laid out in CSS from the concept's frame: the panes' half width, the row height, the frame and the height of the pointed arch above the spring line.
.win { --hw: calc(160 * var(--u)); --rh: calc(155 * var(--u)); --fr: calc(26 * var(--u)); --ak: 1.9174;
position: absolute; left: calc(50% + (134 - 800) * var(--u)); top: calc(var(--fy) + 72 * var(--u));
width: calc(2 * (var(--hw) + var(--fr))); padding: calc(var(--ak) * var(--hw)) var(--fr) var(--fr); }
For now the window is CSS: a stone gradient clipped to an equilateral lancet (two circles on the opposite springers, computed as a polygon), panes drawn with crossing stripes for the lead, and a pool of the accent colour projected on the page. The finished page keeps exactly this for a browser without WebGL.
function archPoly(cx, ys, hw, r, n = 14) { // points of an equilateral arch (circles of radius r at cx∓hw)
const pts = [], top = Math.sqrt(r * r - hw * hw);
for (let k = 0; k <= n; k++) { const y = ys - top * (1 - k / n); const x = cx - (Math.sqrt(r * r - (y - ys) ** 2) - hw); pts.push([x, y]); }
const right = pts.slice().reverse().map(([x, y]) => [2 * cx - x, y]);
return pts.concat(right);
}
.no-fx .fb-win { display: block; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(160deg, #8c8278, #5d554d 45%, #3f3933); filter: drop-shadow(0 0 40px color-mix(in srgb, var(--acc) 35%, transparent)); }

2 · The light
The glass is baked once per resize into a texture the size of the window. Each pane is cathedral glass: its colour is a transmittance, and Beer–Lambert gives every piece its own thickness, so some pieces are deep and some nearly clear. Streaks, seeds and a gold square at each corner of the border finish it.
// Beer–Lambert: thickness varies per piece and within it
float d=0.55+0.85*th+0.35*(pv-0.5);
vec3 T=pow(pc, vec3(d));
The lead cames are a distance field around the diamond quarries, shaded as a rounded strip so they catch a little room light along their edge.
// lead came: rounded, dark, with a soft highlight on the side facing the room light
float prof=clamp(-lead/(2.4*S),0.0,1.0);
vec3 leadC=lin(vec3(0.075,0.072,0.07))*(0.55+0.7*prof)+vec3(0.012)*pow(prof,6.0);
leadC*=0.85+0.3*vnoise(gp*0.4);
// the window, in its own box (outer frame + a 2 px margin), at the scene resolution
const g = L.glass, F = L.F * dpr, hw = g.hw * dpr, rh = g.rh * dpr;
const archH = Math.sqrt((2 * hw + F) ** 2 - hw * hw);
const pad = 3;
The light that falls through the picked pane is a sheared projection of that pane on the page, with the lattice as a soft shadow whose penumbra grows with distance. A new colour does not cut in: it travels across the pool as a wave with a bright leading edge.
float w=smoothstep(uWave-0.16, uWave+0.02, s); // 1 where the new light has not arrived yet
vec3 col=mix(uPTo, uPFrom, w);
col+=uPTo*0.55*exp(-pow((s-uWave+0.05)/0.07,2.0))*step(uWave,1.25)*step(-0.2,uWave); // the bright leading edge
Between the window and the pool, a faint beam with rays, and dust motes that light up only inside it.
// the beam through the room, with rays, and dust motes catching it
vec2 bd=uSE-uSS; float bl=length(bd); vec2 dir=bd/max(bl,1.0); vec2 nr=vec2(-dir.y,dir.x);
vec2 r=c-uSS; float t=dot(r,dir)/max(bl,1.0); float ac=dot(r,nr);
float bw=mix(uSW,uEW,clamp(t,0.0,1.0));
float beam=smoothstep(bw,bw*0.25,abs(ac))*smoothstep(0.0,0.22,t)*smoothstep(1.3,0.55,t);
beam*=0.45+0.8*texture2D(uNoise, vec2(ac/bw*0.22+0.5, t*0.18+uTime*0.003)).a;
if(uDust>0.0){
vec2 dp=c*0.085+vec2(uTime*0.32, -uTime*0.2);
vec2 cell=floor(dp); vec2 o=hash22(cell); float h=hash12(cell+3.1);
vec2 f=fract(dp)-(0.2+0.6*o);
float sz=0.05+0.08*hash12(cell+9.0);
float mote=step(0.955,h)*smoothstep(sz,0.0,length(f))*(0.45+0.55*sin(uTime*1.3+h*40.0));
col+=mix(uBeamC,vec3(1.0),0.5)*mote*(beam*2.2+pm*0.25)*uDust*(1.0-win);
}
The pool is drawn twice: on the stone behind the page, and on a second canvas above the page in
screen blend, at a quarter of the pixels, so the light falls over the headline and the card as well.
#light { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 8; pointer-events: none; mix-blend-mode: screen; }
Now a pick is choreography: the old pane dims, the new one flares with a glint, the wave starts from the window and the accent switches 280 ms later, just as the light reaches the words.
function pick(i, how) {
if (LOCKED) return;
st.lastInput = performance.now();
if (i === st.sel) { st.flare[i] = Math.max(st.flare[i], 0.5); st.glint = [i, -0.2, 0.7]; wake(); return; }
st.from = poolColourNow(); st.to = LIGHT[i].slice();
st.sel = i; st.state = 'picked';
if (RM.matches) { st.wave = 2; st.from = st.to.slice(); st.a.fill(0); st.a[i] = 1; st.offT = shiftFor(i); st.off = st.offT.slice(); st.offV = [0, 0]; }
else { st.wave = -0.3; st.flare[i] = 1; st.glint = [i, -0.25, 1]; }
st.offT = shiftFor(i);
setAccent(RM.matches || !st.fx ? 0 : 280); syncButtons(); emit();
if (!st.fx) placeFallback();
wake();
}
The pool slides to the new pane's projection on a critically damped spring, so it settles without bouncing.
// the pool slides toward the picked pane's projection (critically damped spring)
for (let k = 0; k < 2; k++) {
const w = 9, x = st.off[k] - st.offT[k];
st.offV[k] += (-w * w * x - 2 * w * st.offV[k]) * dt; st.off[k] += st.offV[k] * dt;
if (Math.abs(x) < 0.05 && Math.abs(st.offV[k]) < 0.05) { st.off[k] = st.offT[k]; st.offV[k] = 0; } else poolCh = true;
}
The loop draws at full rate only while something moves, draws the dust and the cloud at 30 fps when the room is still, and after a quiet spell lets them ease to a stop and sleeps.
function tick(now) {
if (document.hidden || !R) { st.running = false; st.sleeping = true; return; }
const dt = Math.min(0.05, Math.max(0, (now - lastT) / 1000)); lastT = now;
const ch = step(dt);
const quiet = now - st.lastInput > SLEEP_AFTER;
if (ch.pane || st.dirty) {
draw(ch.pool || st.dirty ? null : scissorRect()); st.dirty = false;
} else if (!RM.matches && !STATIC && st.speed > 0) {
if (quiet) st.speed = Math.max(0, st.speed - dt * 1.2);
if (now - lastIdle >= 32 || st.speed === 0) { lastIdle = now; draw(null); st.idleFrames++; } // 30 fps idle
} else {
st.running = false; st.sleeping = true; return;
}
requestAnimationFrame(tick);
}

3 · The finish
A first visit opens with a showcase: the room starts dark, the sun comes out behind Cobalt and its light washes across the page, so the idea is clear before you touch anything.
if (firstVisit && !RM.matches && !STATIC && R) {
st.intro = true; st.wave = -0.55; st.from = [0, 0, 0]; st.clock = 0;
} else {
st.a[st.sel] = 1; st.from = st.to.slice(); st.wave = 2;
}
if (st.intro && st.clock < 0.15) T = 0;
If the GPU context is lost, the page switches to the CSS window from stage 1 and builds the renderer again when the context returns. With reduced motion a pick lands at once and the dust stands still.
canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); R = null; st.fx = false; root.classList.add('no-fx'); placeFallback(); });
lightC.addEventListener('webglcontextlost', (e) => { e.preventDefault(); R = null; st.fx = false; root.classList.add('no-fx'); placeFallback(); });
const restore = () => {
if (R) return;
setTimeout(() => {
if (R) return;
const a = canvas.getContext('webgl2') || canvas.getContext('webgl'), b = lightC.getContext('webgl2') || lightC.getContext('webgl');
if (!a || !b || a.isContextLost() || b.isContextLost()) return;
R = startFx(); st.fx = !!R; if (R) { readLayout(); R.resize(layout); st.dirty = true; wake(); }
}, 30);
};


Make it yours
- Your themes are one array,
THEMES: a name, a line, the glass colour and the accent hex. The pre-paint script keeps the same six hex values. - Your glass is a few numbers in the window shader: the thickness range in the Beer–Lambert line, the quarry count, the lead width.
- Accessibility is already there: a real radio group with a roving tabindex, arrow keys, visible focus with a
name tag, a live line that says which theme you are trying, 44 px targets,
prefers-reduced-motionhonoured, the loop asleep when nothing moves, and a CSS window without WebGL.
Vigil is a made-up app. Everything on the page was made in code for this project: the stone, the window, the glass and the light are shaders.
Make the theme change a physical event. The new colour arrives as light that travels from the glass across the page, and the accent switches when it gets there, so the eye follows the change instead of seeing a page repaint.