Library / Tutorial

Ink theme,
step by step.

A dark-mode switch that pours real ink. Turn it on and a stable-fluids simulation curls out of the switch through the page; wherever the ink settles, the page is the dark theme. Milk on the way back. Three stages, each a live page you can run.

3Stages
1Fluid sim
0Libraries

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.

index.html — before the first paint
// 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.

index.html — the switch
<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>
ink.css — on is dark
/* 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.

ink.css — type that inverts itself
.blend { mix-blend-mode: difference; color: #fff; }
app.js — setting the theme
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.

surfaces.js — the two pages, painted
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
}
app.js — the Canvas 2D reveal
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); };
}
Stage 1 · the switchOpen ↗
The Inkwell home page half way through the switch: the night page with its moon landscape revealed in a circle growing out of the switch, over the paper page with the headline Words read better after dark.

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.

fluid.js — one step of the water
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.

ink.js — the jet out of the switch
// 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.

ink.js — plumes, not blobs
// 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.

ink.js — the wet front
// 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.

ink.js — ink and milk
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);
ink.js — the new page, with the ink still in it
// 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.

app.js — stirring
// 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]);
app.js — the bead on hover
// 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.

ink.js — a frame, and sleep
_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(); }
}
Stage 2 · the inkOpen ↗
Ink pouring out of the switch through the Inkwell page: dark plumes and filaments curling across the headline Words read better after dark, the letters inverting to gold over the ink, the sunset card going under.

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.

app.js — the first-visit showcase
} 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 });
}
ink.js — reversing mid-pour
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.

app.js — the fallback, mid-pour
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);
Stage 3 · the finished switchOpen ↗
The finished page on a first visit: the showcase pour a second in, ink flooding the middle of the page and sinking through the headline, paper still at the corners.
Stage 3 · on a phoneOpen ↗
The switch on a phone: ink pouring down from the switch in the nav, across the headline Words read better after dark and into the sunset card, a second after the tap.

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-checked and a busy state, a visible focus ring, 44 px targets, contrast in both themes, the theme applied before the first paint, prefers-reduced-motion honoured, 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.

The one idea worth keeping

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.