This is the whole build of the ink theme switch, in the order it was written: the home page of a made-up journal, Inkwell, with a dark-mode switch in its nav. Turn it on and ink pours out of the switch. It curls through the page in plumes and filaments, and wherever the ink has settled the page underneath is the dark theme. The light page never fades: it is drowned. Turn it off and milk pours back. 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 words are never drawn into the canvas. They stay live text above it, and every glyph inverts itself against whatever is under it, so the type reads on paper, on ink and on night without a second colour.
1 · The switch, without WebGL
Before anything paints, a few lines in the head apply the saved theme, so a return visit never flashes the wrong page
first. The page keeps its own setting and never follows the operating system. Only the current key survives; older
dv76 keys are wiped.
// Pre-paint: apply the saved theme (never the OS), wipe older dv76 keys.
(function () {
var KEY = 'dv76.v1.theme', t = null;
try {
for (var i = localStorage.length - 1; i >= 0; i--) { var k = localStorage.key(i); if (k && k.indexOf('dv76') === 0 && k !== KEY) localStorage.removeItem(k); }
t = localStorage.getItem(KEY);
} catch (e) {}
var d = document.documentElement;
if (t !== 'light' && t !== 'dark') t = null;
d.dataset.theme = t || 'light';
var dark = d.dataset.theme === 'dark';
document.querySelector('meta[name=color-scheme]').content = dark ? 'dark' : 'light';
document.querySelector('meta[name=theme-color]').content = dark ? '#0b0e1a' : '#f3efe6';
})();
The switch is a real button with role="switch" and aria-checked. Its knob is one SVG: a disc
with rays for the sun, and a mask that slides a bite over it for the crescent.
<button class="toggle" id="theme" type="button" role="switch" aria-checked="false" aria-label="Dark mode">
<span class="tg-pill" aria-hidden="true">
<span class="tg-gloss"></span>
<span class="tg-dot"></span>
<span class="tg-knob">
<svg viewBox="0 0 32 32">
<defs><mask id="tg-bite"><rect width="32" height="32" fill="#fff"/><circle class="tg-bite" cx="22.5" cy="10" r="8.6" fill="#000"/></mask></defs>
<g class="tg-rays" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M16 2.6v3.2M16 26.2v3.2M2.6 16h3.2M26.2 16h3.2M6.5 6.5l2.3 2.3M23.2 23.2l2.3 2.3M6.5 25.5l2.3-2.3M23.2 8.8l2.3-2.3"/>
</g>
<circle class="tg-disc" cx="16" cy="16" r="10.4" fill="currentColor" mask="url(#tg-bite)"/>
</svg>
</span>
</span>
</button>
/* on = dark: crescent on the right */
.toggle[aria-checked=true] .tg-pill { background: #1e2640; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12), inset 0 -6px 12px rgba(0, 0, 0, .3), 0 0 0 1px rgba(236, 235, 230, .08), 0 10px 22px -8px rgba(0, 0, 0, .6); }
.toggle[aria-checked=true] .tg-knob { transform: translateX(calc(var(--tw) - var(--th))); color: #ecebe6; }
All the type is white with mix-blend-mode: difference. On paper that comes out near-black, on the night
page it comes out light, and where ink passes under a letter the letter inverts with it.
.blend { mix-blend-mode: difference; color: #fff; }
function setTheme(t, { store = true } = {}) {
if (btn.disabled) return;
theme = t; reflect(); if (store) save();
fx.pour(t, origin());
}
The surfaces under the type (paper or night, the card, the chip, the sun or moon landscape) are painted with Canvas 2D from the live layout, once per theme. Without WebGL the new theme is revealed in a circle growing out of the switch. The finished page keeps exactly this for a browser without WebGL2.
export function paint(g, theme, boxes, W, H, k) {
const T = THEMES[theme], dark = theme === 'dark';
g.save(); g.setTransform(k, 0, 0, k, 0, 0);
g.fillStyle = rgb(T.page); g.fillRect(0, 0, W, H);
// a soft light falloff so the page reads as a lit sheet, not a flat fill
// … the card, the art and the chip, from the live layout
}
pour(to, o) {
if (this.anim) this.anim.finish();
if (to === this.theme) return;
const c = this.cv[to], other = this.cv[this.theme];
other.style.zIndex = 0; c.style.zIndex = 1; this.theme = to;
const R = Math.max(...[[0, 0], [this.W, 0], [0, this.H], [this.W, this.H]].map(([x, y]) => Math.hypot(x - o.x, y - o.y))) + 10;
this.onBusy && this.onBusy(true);
this.anim = reduced.matches
? c.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 250, easing: 'ease' })
: c.animate([{ clipPath: `circle(0px at ${o.x}px ${o.y}px)` }, { clipPath: `circle(${R}px at ${o.x}px ${o.y}px)` }], { duration: 950, easing: 'cubic-bezier(.6,.05,.3,1)' });
this.anim.onfinish = () => { this.anim = null; this._stack(); this.onBusy && this.onBusy(false); this.onDone && this.onDone(to); };
}

2 · The ink
The ink is a stable-fluids simulation on the GPU: velocity and pressure on a coarse grid, the pigment on a finer one. Every step adds buoyancy (ink is heavy and sinks), vorticity confinement (so it curls instead of smearing), solves the pressure so the water stays incompressible, and carries the pigment along.
step(dt) {
const o = this.opts, S = this.sim;
if (o.buoy) { this._run('buoy', this.vel.w, { v: this.vel.r, d: this.dye.r, k: o.buoy, dt, g: o.g }); this.vel.swap(); }
this._run('curl', this.cu, { v: this.vel.r });
this._run('vort', this.vel.w, { v: this.vel.r, c: this.cu, k: o.curl, dt }); this.vel.swap();
this._run('div', this.dv, { v: this.vel.r });
this._run('copy', this.prs.w, { s: this.prs.r, k: .8 }); this.prs.swap();
for (let i = 0; i < o.pressureIters; i++) { this._run('press', this.prs.w, { p: this.prs.r, d: this.dv }); this.prs.swap(); }
this._run('grad', this.vel.w, { p: this.prs.r, v: this.vel.r }); this.vel.swap();
this._run('advect', this.vel.w, { vel: this.vel.r, src: this.vel.r, dt, diss: [o.velDiss, o.velDiss, 0, 0] }); this.vel.swap();
this._run('advect', this.dye.w, { vel: this.vel.r, src: this.dye.r, dt, diss: [o.dyeDiss[0], o.dyeDiss[1], 0, 0] }, S); this.dye.swap();
}
A click pours: a heavy jet leaves the switch, first straight down and then sweeping to the side, every splat with a little randomness from a seeded generator so a pour is the same every time.
// 1 · the jet out of the toggle: down, then sweeping left, heavy drops
const STREAM = 1.2;
if (ts < STREAM) {
R.acc += dt * (ph ? 105 : 190);
while (R.acc >= 1) {
R.acc -= 1;
const a = Math.PI * (.5 + .72 * (ts / STREAM)) + (this.rnd() - .5) * .9; // y-down: π/2 is straight down
const sp = (ph ? 2100 : 5200) + (ph ? 1300 : 4200) * this.rnd();
const x = o.x + (this.rnd() - .5) * 10, y = o.y + 6 + this.rnd() * 10;
const [u, w] = this.uv(x, y);
f.splatVel(u, w, Math.cos(a) * sp * k, -Math.sin(a) * sp * k, this.r2(10 + 9 * this.rnd()));
f.splatDye(u, w, [((ph ? 1.7 : 2.2) + this.rnd()) * this.inkK, 0, 0], this.r2((ph ? 7 : 9) + (ph ? 7 : 9) * this.rnd()));
}
}
Ahead of the body, the pigment is injected as small jets with the colour at their tail, so each one rolls up into the mushroom plume of real ink in water.
// a jet with the pigment at its tail: it rolls up into the mushroom plume of real ink, never a round drop
const sp = (ph ? 520 : 820) + (ph ? 380 : 620) * this.rnd(), a = th + (this.rnd() - .5) * 1.1;
const cx = Math.cos(a), cy = Math.sin(a);
const [u, w] = this.uv(x, y), [u2, w2] = this.uv(x - cx * 12, y - cy * 12);
f.splatVel(u, w, cx * sp * k, -cy * sp * k, this.r2(20 + 12 * this.rnd()));
Behind the ink a wet front runs out from the switch. It is warped by noise so it is never a circle, frayed like ink along paper fibre, and pulled forward by every wisp of pigment that reaches it. Behind it, the page is the new theme.
// the wet front: an expanding, noise-warped edge from the toggle that the pigment leads
vec2 px = vec2(v.x, 1. - v.y) * uDoc;
vec2 q = px - uOrigin; q.y *= uSquash;
// large warp (the flood is never a circle) + fine fray (ink bleeding along paper fibre); ink pulls the front with it
float dist = length(q) + (fbm(px * .0042) - .5) * uFeather * 3. + (vn(px * .011 + 5.) - .5) * 34. + (vn(px * .09) - .5) * 6.
- min(d, 2.) * 26.; // the edge follows every wisp of pigment that reaches it
float f = clamp((uFront + min(body, 2.) * uFeather * .9 - dist) / 16., -1.2, 4.);
// alone, only the densest settled ink turns the page; the front does the rest
float m = smoothstep(.0, .9, f + smoothstep(1.4, 2.3, body) * 1.6 - .2);
m = max(m, uMix);
The colour is Beer–Lambert: each channel is absorbed at its own rate, so thin ink has a blue sheen and thick ink goes black. On the way back the pigment is milk, which scatters light instead of absorbing it.
float ink = step(0., uPig);
vec3 sig = mix(vec3(1.65, 1.98, 2.64), vec3(3.62, 3.12, 2.06) * uSig, ink);
vec3 pigC = mix(vec3(.80, .79, .75), vec3(.006, .008, .022), ink);
vec3 Tr = exp(-dd * sig);
vec3 col = Oc * Tr + pigC * (1. - Tr);
// inside the turned region: the new page, the pigment's structure still faintly in it until it settles out
float deep = smoothstep(1.0, 2.2, sm*.5 + sm2*.5);
float band = (1. - deep) * (1. - uSettle);
vec3 res = mix(Nc, pigC, clamp(rim * .32 + d * .035, 0., .45) * band);
While the ink moves, any pointer dragged through it pushes the water. Hovering or focusing the switch lets a bead of the pigment a click would pour tremble out of it.
// stirring: while the ink moves, any pointer moving through it pushes the fluid
const last = new Map();
addEventListener('pointermove', (e) => {
const p = last.get(e.pointerId); last.set(e.pointerId, [e.pageX, e.pageY]);
if (!p || !fx || !fx.busy) return;
fx.stir(e.pageX, e.pageY, e.pageX - p[0], e.pageY - p[1]);
// hover / focus: a bead of the pigment a click would pour trembles out of the toggle
const teaseAt = () => { const o = origin(); return { x: o.x, y: o.bottom - 4 }; };
btn.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse' && !btn.disabled) fx && fx.tease(true, teaseAt()); });
Every pour completes: the front reaches the farthest corner, the residue settles out, the simulation is cleared and the loop stops, so the finished page costs nothing.
_frame(now) {
this.raf = 0; if (this.lost) return;
const dt = this.last ? Math.min(1 / 30, Math.max(1 / 240, (now - this.last) / 1000)) : 1 / 120;
this.last = now;
if (!this.held) this._step(dt);
this.frames++;
const R = this.run;
if (R && !this.held && R.ts >= R.T1 + R.settle + .05) { this._finish(); return; }
this._composite(null, now / 1000);
if (this.run || this.tz.on || this.tz.alive > 0 || this.held) this.raf = requestAnimationFrame((t) => this._frame(t));
else { this.fluid.reset(); this.drawStatic(); }
}

3 · The finish
A first visit opens on the showcase: the ink is already leaving the switch in the first frame and the page pours itself into the dark theme, so the idea is clear before you touch anything. Clicking again while the ink still moves turns the pour around from the frame on screen.
} else if (intro) {
// first visit: open on the showcase, the ink already leaving the toggle in the first frame
theme = 'dark'; reflect();
fx.pour('dark', origin(), { pre: mode === 'webgl' ? .3 : 0 });
}
if (this.run) { // reversed mid-way: start from what is on screen
const s = this.snaps[this.snapI]; this.snapI ^= 1;
this._composite(s.fb); this._clearDye(); from = s.t;
}
If the GPU context is lost, even mid-pour, the page switches to the Canvas 2D reveal from stage 1 and finishes the switch there. With reduced motion there is no pour at all, only a short dissolve.
function toFallback() {
const was = fx, busy = was && was.busy;
mode = 'fallback'; root.classList.add('no-fx');
fx = new FallbackFX(); wire(fx);
if (busy) { fx.layout(theme === 'dark' ? 'light' : 'dark'); fx.pour(theme, origin()); } else fx.layout(theme);


Make it yours
- Your pages are the two themes in
surfaces.js(paper and night, card, chip, the landscape) and the type colours in the style sheet; the ink only ever pours one painted page into the other. - Your ink is a few numbers: the absorption per channel in the Beer–Lambert line, the pigment per splat
(
?ink=), the curl, and how hard the arc is flung along the nav (?fling=). - Accessibility is already there: a real switch with
aria-checkedand a busy state, a visible focus ring, 44 px targets, contrast in both themes, the theme applied before the first paint,prefers-reduced-motionhonoured, the loop asleep when nothing moves, and the Canvas 2D reveal without WebGL.
Inkwell is a made-up journal. Everything on the page was made in code for this project: the paper, the landscape and the ink are Canvas 2D and shaders.
Make the theme change a material. The dark page is not faded in: it arrives as ink that spreads from the switch you touched, and the page under it is the new theme, so the eye follows the change instead of seeing a repaint.