Library / Tutorial

Mercury toggle switches,
step by step.

A settings panel where every switch is a bead of liquid chrome. Flip one and it stretches into a dumbbell, squashes against the wall and lands with a tock; drag it, flick it; slide a mercury thread and it ripples. Six stages, each a live page you can run.

6Stages
1Shader
0Libraries

This is the whole build of the mercury toggle switches, in the order it was written. It is a settings panel for a made-up design system, Mercury UI: six switches and two sliders on graphite glass. Every switch is a bead of liquid chrome. Flip one and the bead stretches into a dumbbell as it rolls, stops dead at the end of its groove, squashes and wobbles, and lands with a small metallic tock. The sliders are mercury threads: a heavy bead lags behind the value and the thread ripples. Each stage below is the panel 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, no library and no build step: one HTML page, one style sheet, a settings module, the renderer (an ES module with one fragment shader), a Canvas2D painter for the studio the chrome reflects, and a few lines of Web Audio. The text and the controls are ordinary HTML: real buttons and native range inputs. WebGL only draws the chrome over them.

1 · The panel, in HTML and CSS

Start with what a screen reader and a keyboard need. A switch is a button with role="switch" and aria-checked, named by its title and described by its subtitle. The OFF and ON words beside it are decoration, hidden from assistive technology.

index.html — one switch row
<li class="row" data-key="system">
  <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4Z"/><path d="M19.4 13.5a7.7 7.7 0 0 0 0-3l2-1.5-2-3.4-2.3 1a7.6 7.6 0 0 0-2.6-1.5L14.2 2.6h-4l-.4 2.5a7.6 7.6 0 0 0-2.6 1.5l-2.3-1-2 3.4 2 1.5a7.7 7.7 0 0 0 0 3l-2 1.5 2 3.4 2.3-1a7.6 7.6 0 0 0 2.6 1.5l.4 2.5h4l.4-2.5a7.6 7.6 0 0 0 2.6-1.5l2.3 1 2-3.4Z"/></svg>
  <div class="txt"><label class="ttl" id="l-system" for="sw-system">System enabled</label><span class="sub" id="d-system">Enable system functions</span></div>
  <div class="ctl"><span class="w w-off" aria-hidden="true">OFF</span><button class="sw" id="sw-system" type="button" role="switch" aria-checked="true" aria-labelledby="l-system" aria-describedby="d-system"><span class="groove"></span><span class="bead-css"></span></button><span class="w w-on" aria-hidden="true">ON</span></div>
</li>

A slider is a native input type="range" inside a 44 px rail, so it already has a label, a value, arrow keys, Home and End, and touch.

index.html — one slider
<div class="sl" data-key="volume">
  <div class="txt"><label class="ttl" for="sl-volume">Volume</label><span class="sub" id="d-volume">Set output volume</span></div>
  <div class="rail"><span class="groove"></span><span class="fill-css"></span><input type="range" id="sl-volume" min="0" max="100" step="1" value="72" aria-describedby="d-volume"></div>
</div>

The panel is graphite glass: two gradients, a bright rim along the top, and a box shadow that lifts it off the room. A switch's groove is a dark pill with an inset shadow.

mercury.css — the glass panel
.panel { position: relative; border-radius: 24px; padding: 38px 56px 44px; outline: none; isolation: isolate;
  background:
    linear-gradient(180deg, rgba(255, 255, 255, .055), transparent 26%),
    linear-gradient(162deg, var(--glass-hi) 0%, var(--glass-mid) 42%, var(--glass-lo) 100%);
  box-shadow: inset 0 1px 0 var(--rim), inset 0 0 0 1px var(--rim-lo), inset 0 -1px 0 rgba(0, 0, 0, .25), var(--panel-shadow); }
mercury.css — the groove
.groove { position: absolute; left: 0; right: 0; top: 50%; height: var(--th); margin-top: calc(var(--th) / -2); border-radius: 999px; pointer-events: none;
  background: linear-gradient(180deg, var(--groove) 0%, var(--groove-2) 100%);
  box-shadow: inset 0 3px 7px rgba(0, 0, 0, .55), inset 0 -1px 0 var(--groove-rim), 0 1px 0 rgba(255, 255, 255, .07), 0 0 0 1px rgba(0, 0, 0, .25); }

The chrome bead in this stage is CSS: a radial gradient with a white glint, slid along the groove by aria-checked. It stays in the finished build as the fallback for browsers without WebGL.

mercury.css — the CSS chrome bead
/* CSS chrome bead: only without WebGL (html.no-fx) */
.bead-css { position: absolute; left: 2px; top: calc(50% - var(--th) / 2 + 2px); width: calc(var(--th) - 4px); height: calc(var(--th) - 4px); border-radius: 50%; display: none;
  background: radial-gradient(circle at 36% 30%, #fff 0 8%, #e3e9ef 16%, #9aa4b0 38%, #2c323a 64%, #7d8996 84%, #cfd8e2 100%);
  box-shadow: 0 3px 6px rgba(0, 0, 0, .55), inset 0 -2px 3px rgba(0, 0, 0, .35); transition: transform .42s cubic-bezier(.5, 1.6, .4, 1); }
.no-fx .bead-css { display: block; }
.no-fx .sw[aria-checked="true"] .bead-css { transform: translateX(calc(var(--tw) - var(--th))); }
.no-fx .sw[aria-checked="true"] .groove { background: linear-gradient(180deg, color-mix(in srgb, var(--ice) 35%, var(--groove)), color-mix(in srgb, var(--ice) 55%, var(--groove-2))); }

The range input is transparent over the rail; without WebGL it shows a chrome thumb, with a CSS fill under it that reads its width from a --v variable.

mercury.css — the range input over the rail
.rail input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; touch-action: pan-y; -webkit-appearance: none; appearance: none; background: none; }
mercury.css — the CSS fill and thumb
.no-fx .fill-css { display: block; position: absolute; left: 0; top: 50%; height: 6px; margin-top: -3px; width: calc(var(--v, .5) * 100%); border-radius: 99px;
  background: linear-gradient(180deg, #f4f7fa, #9aa4af 55%, #dfe5eb); pointer-events: none; }
.no-fx .rail input { opacity: 1; }

The script keeps the settings and paints them. That is all a click needs, and Space and Enter already click a button.

app.js — paint a setting
function set(k, v) {
  if (typeof DEFAULTS[k] === 'boolean') {
    v = !!v;
    settings[k] = v; paintSwitch(k);
  } else {
    v = Math.round(Math.max(0, Math.min(100, Number(v))));
    settings[k] = v;
    const inp = $(`[data-key="${k}"] input`);
    if (Number(inp.value) !== v) inp.value = v;
    inp.setAttribute('aria-valuetext', `${v}%`);
    $(`[data-key="${k}"] .rail`).style.setProperty('--v', v / 100);
  }
}
app.js — a click flips a switch
for (const b of switches) {
  const k = keyOf(b);
  b.addEventListener('click', () => set(k, !settings[k]));
}

On a phone the panel becomes a full-width list. The OFF and ON words go, and the state is written beside each switch from a data-state attribute.

mercury.css — the state word on a phone
.ctl::before { content: attr(data-state); min-width: 24px; text-align: right; font: 500 10.5px/1 var(--display); letter-spacing: .08em; color: var(--ink-2); }
Stage 1 · the panelOpen ↗
The Mercury UI system settings panel in graphite glass: six switch rows in two columns with CSS chrome beads, Night shift off, and Volume and Brightness sliders with chrome thumbs.

2 · What each setting does, and the keyboard

Next the settings do things. System enabled is the master switch: off, it disables Notifications, Sounds, Bluetooth and Volume. The switches get aria-disabled (so they stay focusable and are read as dimmed), the range gets disabled, and a live region says what happened.

app.js — the master switch
function applyDisabled() {
  const off = !settings.system;
  for (const k of CHILDREN) {
    const host = $(`[data-key="${k}"]`);
    host.classList.toggle('is-disabled', off);
    const sw = $('.sw', host), inp = $('input', host);
    if (sw) { if (off) sw.setAttribute('aria-disabled', 'true'); else sw.removeAttribute('aria-disabled'); }
    if (inp) inp.disabled = off;
  }
}
index.html — the live region
<div class="sr-only" id="live" aria-live="polite"></div>

Night shift lays a warm amber layer over the whole page. Dark mode off switches to a silver theme: the tokens change on :root.theme-light, and the color-scheme meta follows, so form controls and scroll bars match. The theme is the page's own: it never follows the operating system.

app.js — night shift and the theme
function applyLook() {
  applyLight();
  root.classList.toggle('theme-light', !settings.darkmode);
  root.classList.toggle('night', settings.nightshift);
  metaScheme.content = settings.darkmode ? 'dark' : 'light';
  metaTheme.content = settings.darkmode ? '#1b1f25' : '#dfe4e9';
}
mercury.css — the silver theme
:root.theme-light {
  --bg-0: #b9c0c8; --bg-1: #e7ebef;
  --ink: #14181d; --ink-2: #4b5560; --ink-3: #7a838d; --ink-ico: #2b333c;
  --glass-hi: rgba(255, 255, 255, .72); --glass-mid: rgba(236, 240, 244, .62); --glass-lo: rgba(208, 216, 225, .7);
  --rim: rgba(255, 255, 255, .85); --rim-lo: rgba(120, 130, 142, .25); --rule: rgba(20, 30, 40, .12);
  --groove: #c3cad2; --groove-2: #d6dce2; --groove-rim: rgba(255, 255, 255, .9);
  --ice: rgb(110, 165, 215); --focus: #1f6fd1;
  --glow-left: rgba(255, 255, 255, .85); --glow-right: rgba(120, 165, 225, .45); --glow-top: rgba(255, 255, 255, .6);
  --beam: rgba(255, 255, 255, .28);
  --panel-shadow: 0 40px 90px -36px rgba(40, 52, 66, .55), 0 10px 26px -12px rgba(40, 52, 66, .35);
  --bubble: rgba(250, 252, 255, .94);
  color-scheme: light;
}
mercury.css — the night veil
.night-veil { position: fixed; inset: 0; z-index: 40; pointer-events: none; background: rgb(255, 146, 52); opacity: 0; visibility: hidden; transition: opacity .9s ease, visibility 0s linear .9s; }
.night .night-veil { opacity: .13; visibility: visible; transition: opacity .9s ease, visibility 0s; }
.theme-light.night .night-veil { opacity: .16; }

Brightness turns the room's lights up and down. The glows are their own compositor layers, so only their opacity changes and nothing is repainted.

app.js — brightness
function applyLight() {
  const b = settings.brightness / 100;
  const o = (0.28 + 0.72 * Math.pow(b, 0.8)).toFixed(3);
  for (const g of glows) g.style.opacity = o;
}

The left and right arrow keys set a switch off and on, as on a native switch.

app.js — the arrow keys
b.addEventListener('keydown', (e) => {
  if (b.getAttribute('aria-disabled') === 'true') return;
  if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
    e.preventDefault();
    const on = e.key === 'ArrowRight';
    if (settings[k] !== on) set(k, on, { user: true });
  }
});
Stage 2 · the settingsOpen ↗
The panel with System enabled switched off, so Notifications, Sounds, Bluetooth and Volume are dimmed, and Night shift on, which lays a warm amber veil over the whole page.

3 · Liquid chrome in WebGL

Now the chrome. One canvas covers the panel; every control is drawn in its own scissor box, so the canvas costs only the pixels around the beads.

index.html — one canvas over the panel
<canvas class="fx" aria-hidden="true"></canvas>

The shape is a 2D signed distance: a circle for a switch's bead; for a slider, a thread from the start of the groove to its bead, joined to the bead with a smooth minimum. Each point also gets a local radius: the thread's radius on the thread, the bead's on the bead, blended between.

mercury.js — the shape and its local radius
vec2 shape(vec2 p){
  float h=length(p-uHead.xy)-uHead.z;
  float Rh=uHead.z;
  float wh=smoothstep(Rh*1.6, Rh*0.95, length(p-uHead.xy));
  float d; float R;
  if(uMode<0.5){
    d=h; R=Rh*wh;                                    // the bead's radius on the bead, fading off it
  } else {
    float tr=threadR(p.x);
    float th=sdSeg(p, vec2(uTrack.x+tr, uHead.y), uHead.xy)-tr;
    d=smin(h, th, uHead.z*0.9);
    R=min(Rh, sqrt(tr*tr+pow(Rh*wh,2.0)));   // the thread's radius on the thread, the bead's on the bead
  }
  return vec2(d, max(R, 0.75*uDpr));
}

The distance becomes a height: a quarter circle of that local radius, so the thread is a round cylinder and the bead a dome, with no crease where they meet. Two more samples give the slope, and the slope gives a real surface normal.

mercury.js — height
float height(vec2 s){ float R=s.y; float t=clamp(-s.x,0.,R); float x=R-t; return sqrt(max(R*R-x*x,0.)); }
mercury.js — the normal
vec2 s0=shape(p);
float e=0.75;
float H=height(s0);
float Hx=height(shape(p+vec2(e,0.)));
float Hy=height(shape(p+vec2(0.,e)));
vec3 n=normalize(vec3(-(Hx-H), -(Hy-H), e));

Chrome is mostly reflection, so the normal's reflection looks up a studio panorama (equirectangular), turned by a slow yaw. Night shift warms it, Brightness scales it, and the silver theme cross-fades to a lighter studio.

mercury.js — the studio in the chrome
vec3 env(vec3 r){
  vec3 d=vec3(r.x,-r.y,r.z);
  float c=cos(uYaw), s=sin(uYaw);
  d.xz=mat2(c,-s,s,c)*d.xz;
  float az=atan(d.x,d.z)/6.2831853;                       // -0.5..0.5, jumps at the back
  float u=d.z<0.0 ? fract(az+1.0)+0.5 : az+0.5;          // at the back use a parametrisation that is continuous there
  vec2 uv=vec2(u, acos(clamp(d.y,-1.0,1.0))/3.1415927);
  vec3 a=toLin(texture2D(uEnvA,uv).rgb);
  vec3 b=toLin(texture2D(uEnvB,uv).rgb);
  vec3 e=mix(a,b,uLight);
  e*=mix(vec3(1.0), vec3(1.12,0.86,0.62), uWarm);
  return e*uExpo;
}

On top come Fresnel, a darker rim where the surface turns away, a sharp key glint, a blue rim light and a soft tone curve.

mercury.js — the chrome
// ---- the chrome ----
vec3 r=vec3(2.0*n.z*n.x, 2.0*n.z*n.y, 2.0*n.z*n.z-1.0);
vec3 c=env(r);
float fres=0.7+0.3*pow(1.0-n.z,4.0);
c*=fres*vec3(0.93,0.955,0.985);
c*=mix(0.45,1.0,smoothstep(0.0,0.3,n.z));
vec3 L=normalize(vec3(-0.42,-0.78,0.5));
float sp=max(dot(r,L),0.);
c+=(pow(sp,140.0)*4.0+pow(sp,18.0)*0.22)*uExpo*mix(vec3(1.0),vec3(1.0,0.85,0.66),uWarm);
vec3 L2=normalize(vec3(0.62,0.35,0.7));
c+=pow(max(dot(r,L2),0.),90.0)*0.6*uExpo*vec3(0.62,0.78,1.0);
c=c/(1.0+c*0.22);
float lum=dot(c,vec3(0.3,0.59,0.11));
c=mix(c, vec3(lum)*0.55, uDim*0.75);
c=pow(max(c,0.0), vec3(1.0/2.2));

Behind an ON bead the groove fills with pale ice light, with a thin line along the top of its glass.

mercury.js — the lit groove
float behind=smoothstep(uHead.x+R*0.15, uHead.x-R*0.7, p.x);
float vy=clamp((p.y-cy)/hh,-1.,1.);
float body=0.30+0.42*smoothstep(-0.9,0.95,vy);
float lip=0.32*exp(-pow((vy+0.62)/0.1,2.0));                 // a thin light line along the top of the groove glass
float near=0.5*exp(-abs(p.x-uHead.x)/(R*0.9));
float edge=smoothstep(0.0,-4.0*uDpr,tsd);
float g=uGlow*inG*behind*(body*edge+lip+near*edge)*mix(1.0,0.4,uDim);
vec3 ice=uIce*mix(vec3(1.0),vec3(1.15,0.86,0.62),uWarm);
under+=ice*g*0.8; ua+=g*0.55;

The studio is painted once in Canvas2D, 1024 × 512: soft boxes, a dome light, strip lights, a glossy desk, and behind the viewer (what a bead shows in its centre) a window wall with mullions and a skyline.

env.js — the window wall
// the window wall behind the viewer: wide bright panes with thin mullions, a transom, a soft skyline
const wu0 = .34, wu1 = .66, wv0 = .3, wv1 = .488;
box(wu0 - .015, wv0 - .02, wu1 + .015, wv1, 'rgba(225,238,255,.6)', 14, 4);
gr = g.createLinearGradient(0, Y(wv0), 0, Y(wv1));
gr.addColorStop(0, '#ffffff'); gr.addColorStop(.6, '#e3edf8'); gr.addColorStop(1, '#a9bed6');
g.fillStyle = gr; g.fillRect(X(wu0), Y(wv0), X(wu1 - wu0), Y(wv1 - wv0));
g.fillStyle = 'rgba(92,110,132,.45)'; g.beginPath(); g.moveTo(X(wu0), Y(wv1));
for (let i = 0; i <= 48; i++) { const u = wu0 + (wu1 - wu0) * i / 48; const h = .02 + .02 * Math.abs(Math.sin(i * 1.7) * Math.cos(i * .63)) + (i % 9 === 4 ? .018 : 0); g.lineTo(X(u), Y(wv1 - h)); }
g.lineTo(X(wu1), Y(wv1)); g.closePath(); g.fill();
g.fillStyle = L ? 'rgba(60,68,80,.85)' : 'rgba(40,46,56,.9)';
for (let i = 0; i <= 3; i++) { const u = wu0 + (wu1 - wu0) * i / 3; g.fillRect(X(u) - 2, Y(wv0), 4, Y(wv1 - wv0)); }
g.fillRect(X(wu0), Y(.37) - 1.5, X(wu1 - wu0), 3);

Every frame each control hands the shader its groove and its bead.

mercury.js — draw
for (const S of this.switches.values()) {
  if (!S.rect) continue;
  const R = S.rect, r = S.r;
  box(R.x - 10, R.y - 10, R.x + R.w + 10, R.y + R.h + 12);
  gl.uniform4f(u.uTrack, R.x * d, R.y * d, (R.x + R.w) * d, (R.y + R.h) * d);
  gl.uniform3f(u.uHead, S.hx * d, S.cy * d, r * d);
  gl.uniform1f(u.uGlow, S.glow); gl.uniform1f(u.uDim, S.dim);
  gl.uniform4f(u.uRip, 0, 0, 0, 0);
  gl.drawArrays(gl.TRIANGLES, 0, 3);
}

At rest the light still drifts across the chrome, so the loop redraws about 30 times a second. With reduced motion it stops, and it pauses while the panel is off screen or the tab is hidden.

mercury.js — the idle drift
// idle: light wanders slowly across the chrome (two incommensurate swells)
const t = (now - this.t0) / 1000;
if (!this.reduced) this.look.yaw = 0.2 * Math.sin(t * 0.26) + 0.07 * Math.sin(t * 0.61 + 1.3);
// moving → every frame; idle drift → about 30 fps (slow light, nobody sees the step); reduced motion → sleep
if (busy || this.dirty || now - this.lastDraw > 31) { this.dirty = false; this.draw(); this.lastDraw = now; if (this.onFrame) this.onFrame(busy); }
if (!busy && this.reduced) { this.running = false; return; }
requestAnimationFrame(this._tick);
app.js — sleep when nobody can see it
let onScreen = true;
new IntersectionObserver((es) => { onScreen = es[es.length - 1].isIntersecting; fx.pause(!hasFx || !onScreen || document.hidden); }).observe(panel);
document.addEventListener('visibilitychange', () => fx.pause(!hasFx || document.hidden || !onScreen));

Without WebGL (or with ?nofx in the URL) the CSS chrome of stage 1 stays. A value bubble rides on a slider's bead while it is hovered, focused or pressed.

app.js — WebGL, or the CSS chrome
const fx = new Mercury(canvas);
const hasFx = !fx.failed && !params.has('nofx');
root.classList.toggle('no-fx', !hasFx);
if (!hasFx) fx.pause(true);
app.js — the value bubble
function placeBubble(r) {
  const k = keyOf(r), b = $('.bubble', r);
  const L = fx.sliders.get(k);
  const x = hasFx ? fx.sliderX(k) - L.rect.x : 12 + (r.clientWidth - 24) * (settings[k] / 100);   // fallback: the 24 px native thumb's centre
  const d = devicePixelRatio || 1; b.style.transform = `translateX(${Math.round(x * d) / d}px)`;  // whole device pixels: the label stays sharp
}
fx.onFrame = () => { for (const r of active) placeBubble(r); };
Stage 3 · liquid chromeOpen ↗
The panel with WebGL chrome beads that reflect a studio with a window wall and soft boxes, the lit ice-blue grooves behind the ON beads, and a 54% value bubble under the Brightness bead.

4 · Springs: the dumbbell, the wall, drag and flick

The bead was jumping. Now it is a heavy head and a lighter tail on springs. The head is pulled to its end, the tail follows the head more slowly, and the stretch follows the head's speed. When the head hits the end wall it stops dead, loses its speed, and the stretch is kicked negative: a squash. The springs run in sub-steps of 1/240 s, so the wall holds at any frame rate.

mercury.js — the switch springs
_stepSwitch(S, h) {
  const target = S.drag != null ? S.drag : (S.on ? S.x1 : S.x0);
  const k = S.drag != null ? 520 : 230, c = 2 * Math.sqrt(k) * (S.drag != null ? 0.9 : 0.5);
  S.hv += (k * (target - S.hx) - c * S.hv) * h; S.hx += S.hv * h;
  // the end walls: the bead stops dead, squashes and the impact is heard
  const lo = S.x0 - 1.2, hi = S.x1 + 1.2;
  if (S.hx > hi || S.hx < lo) {
    const v = Math.abs(S.hv);
    S.hx = clamp(S.hx, lo, hi);
    if (v > 25) { S.sv -= Math.min(v, 900) * 0.0062; if (this.onImpact && performance.now() - (S.lastImpact || 0) > 120) { S.lastImpact = performance.now(); this.onImpact(S.id, v); } }
    S.hv = -S.hv * 0.12;
  }
  const kT = 62, cT = 2 * Math.sqrt(kT) * 0.92;
  S.tv += (kT * (S.hx - S.tx) - cT * S.tv) * h; S.tx += S.tv * h;
  S.tx = clamp(S.tx, S.x0 - 1, S.x1 + 1);
  const sT = clamp(Math.abs(S.hv) / 1700, 0, 0.2);
  S.sv += (360 * (sT - S.s) - 8.5 * S.sv) * h; S.s += S.sv * h;
  S.s = clamp(S.s, -0.32, 0.4);
  S.glow += ((S.on ? 1 : 0) - S.glow) * Math.min(1, h * 6);
  S.dim += (S.dimT - S.dim) * Math.min(1, h * 7);
}
mercury.js — sub-steps
const sub = Math.max(1, Math.ceil(dt / (1 / 240))), h = dt / sub;
for (let i = 0; i < sub; i++) {
  for (const S of this.switches.values()) if (S.placed) this._stepSwitch(S, h);
  for (const L of this.sliders.values()) if (L.placed) this._stepSlider(L, h);
}

In the shader the head becomes an ellipse stretched along the groove, the tail a second circle, and a neck (a capsule) joins them, all with smooth minimums, so the bead pulls into a dumbbell and snaps back.

mercury.js — the stretched head
vec2 headAxes(){ float s=uSq.x; return uHead.z*vec2(1.0+s, 1.0/(1.0+s*0.85)); }
mercury.js — head, tail and neck
float n=sdSeg(p,uHead.xy,uTail.xy)-uSq.y;
float k=uHead.z*0.75;
d=smin(smin(h,t,k),n,k*0.8);
float rn=max(uSq.y,1.0);
R=min(Rh, sqrt(rn*rn+pow(Rh*wh,2.0)+pow(uTail.z*wt,2.0)));   // smooth: no crease where head and tail meet
mercury.js — the tail and neck grow with the gap
const R = S.rect, r = S.r, gap = Math.abs(S.hx - S.tx);
const f = clamp(gap / (r * 0.6), 0, 1), fs = f * f * (3 - 2 * f);
const rt = r * (0.8 - 0.22 * clamp(gap / (r * 2), 0, 1)) * fs;
const neck = r * 0.36 * (1 - gap / (r * 2.6)) * fs - (1 - fs) * r;

A bead can be dragged. A press is not a drag until it moves 5 px sideways; a vertical move gives the gesture back to the page, because the button has touch-action: pan-y. Once dragging, the button captures the pointer and the head follows the hand on a stiff spring.

app.js — drag
b.addEventListener('pointermove', (e) => {
  if (!drag || drag.b !== b || e.pointerId !== drag.id) return;
  const dx = e.clientX - drag.x0, dy = e.clientY - drag.y0;
  if (!drag.moved) {
    if (Math.abs(dx) < 5) { if (Math.abs(dy) > 8) drag = null; return; }
    drag.moved = true; try { b.setPointerCapture(e.pointerId); } catch (_) { /* gone */ }
    b.classList.add('is-dragging');
  }
  if (hasFx) fx.dragSwitch(k, e.clientX - drag.left);
});

On release the bead lands where its position plus 0.12 s of its speed points, so a flick is enough. The drag never also counts as a click.

mercury.js — where a release lands
// where a released drag should land: position plus the flick
releaseSwitch(id) {
  const S = this.switches.get(id); if (!S) return null;
  const f = (S.hx - S.x0) / (S.x1 - S.x0), proj = f + S.hv * 0.12 / (S.x1 - S.x0);
  S.drag = null; this.kick();
  return proj > 0.5;
}
app.js — release
const end = (e, cancel) => {
  if (!drag || drag.b !== b || e.pointerId !== drag.id) return;
  const d = drag; drag = null; b.classList.remove('is-dragging');
  if (!d.moved) return;
  swallowClick = true; setTimeout(() => { swallowClick = false; }, 0);
  if (cancel || !hasFx) { set(k, settings[k]); return; }
  const on = fx.releaseSwitch(k);
  set(k, on, { user: true });
};

A slider's bead gets the same treatment: it lags the value, stretches with its speed, and leaves a lump on the thread that stays attached.

mercury.js — the slider bead
_stepSlider(L, h) {
  const target = mix(L.x0, L.x1, L.v);
  const k = 190, c = 2 * Math.sqrt(k) * 0.72;
  L.xv += (k * (target - L.x) - c * L.xv) * h; L.x += L.xv * h;
  L.x = clamp(L.x, L.x0 - 2, L.x1 + 2);
  const kT = 70, cT = 2 * Math.sqrt(kT) * 0.9;
  L.tv += (kT * (L.x - L.tx) - cT * L.tv) * h; L.tx += L.tv * h;
  const lim = L.br * 1.7; if (Math.abs(L.tx - L.x) > lim) L.tx = L.x + Math.sign(L.tx - L.x) * lim; // the lump stays attached
  const sT = clamp(Math.abs(L.xv) / 1500, 0, 0.32);
  L.sv += (340 * (sT - L.s) - 9 * L.sv) * h; L.s += L.sv * h;
  L.dim += (L.dimT - L.dim) * Math.min(1, h * 7);
}
Stage 4 · springsOpen ↗
Bluetooth 150 milliseconds after a click: the chrome bead stretched into a dumbbell between the ON and OFF ends of its groove.

5 · Ripples and sound

A moving slider bead builds up energy. The energy sends a wave down the thread behind the bead, and rings across the glass around it, squashed sideways like light on a curved surface.

mercury.js — energy
L.e = Math.max(L.e * Math.exp(-h * 1.9), clamp(Math.abs(L.xv) / 520, 0, 1));
L.ph += h * 7.5;
mercury.js — the thread ripples
float threadR(float x){
  float dx=max(uHead.x-x,0.);
  float w=uRip.x*sin(dx/(5.5*uDpr)-uRip.y*1.7)*exp(-dx/(80.0*uDpr));
  return uRip.z*(1.0+0.42*w);
}
mercury.js — rings on the glass
// rings on the glass around the moving bead
vec2 q=(p-uHead.xy)/uDpr; q.x*=0.55;
float dist=length(q);
float ring=uRip.x*sin(dist*0.62-uRip.y*2.4)*exp(-dist/24.0)*smoothstep(R/uDpr*0.9, R/uDpr*1.7, dist);
under+=vec3(0.86,0.92,1.0)*max(ring,0.0)*0.22; ua+=max(ring,0.0)*0.16+max(-ring,0.0)*0.14;

The wall impact gets a sound. Since stage 4 the renderer reports every impact faster than 25 px/s (onImpact); now the page plays a synthesised tock for it, as strong as the impact, at the Volume level, a little higher or lower for each switch, and only while Sounds and System are on.

app.js — the tock
const warmOnce = () => { removeEventListener('pointermove', warmOnce); removeEventListener('keydown', warmOnce); setTimeout(warm, 0); };
addEventListener('pointermove', warmOnce, { passive: true }); addEventListener('keydown', warmOnce);
const soundOn = () => settings.sounds && settings.system;
fx.onImpact = (id, v) => { if (soundOn()) tock(Math.min(1, v / 650), settings.volume / 100, PITCH[id] || 1); };

The tock is a quick falling sine (the liquid body) and two short inharmonic partials (the metal). Nothing is downloaded.

sound.js — a tock
export function tock(strength = 1, volume = 0.7, pitch = 1) {
  if (!ctx || ctx.state !== 'running' || volume <= 0) return;
  const t = ctx.currentTime, gain = Math.min(1, strength) * volume * volume * 0.5;
  if (gain < 0.004) return;
  const out = ctx.createGain(); out.gain.value = gain; out.connect(bus);
  // body: a quick downward sine (a droplet landing)
  const o = ctx.createOscillator(), g = ctx.createGain();
  o.type = 'sine'; o.frequency.setValueAtTime(420 * pitch, t); o.frequency.exponentialRampToValueAtTime(150 * pitch, t + 0.09);
  g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.9, t + 0.004); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.14);
  o.connect(g).connect(out); o.start(t); o.stop(t + 0.16);
  // ring: two inharmonic partials, very short
  for (const [f, a, d] of [[2380, 0.16, 0.07], [3710, 0.08, 0.045]]) {
    const p = ctx.createOscillator(), pg = ctx.createGain();
    p.type = 'sine'; p.frequency.value = f * pitch;
    pg.gain.setValueAtTime(0.0001, t); pg.gain.exponentialRampToValueAtTime(a, t + 0.002); pg.gain.exponentialRampToValueAtTime(0.0001, t + d);
    p.connect(pg).connect(out); p.start(t); p.stop(t + d + 0.02);
  }
}

Opening the audio device costs one long main-thread task, so it is paid on the first pointer move, suspended, and a real gesture only resumes it. Nothing ever plays by itself. A slider ticks softly at every 5 %.

sound.js — open early, play late
// Opening the audio device costs one long main-thread task (~250 ms measured on macOS Chromium). It is paid when the
// pointer first moves over the page (the context is created suspended, nothing plays), not on the first click, where it
// would stall the bead's first travel. A real gesture then only resumes it.
function create() {
  if (ctx) return;
  const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
  ctx = new AC();
  bus = ctx.createDynamicsCompressor(); bus.threshold.value = -14; bus.ratio.value = 4; bus.connect(ctx.destination);
}
export function warm() { create(); }
export function unlock() {
  create();
  if (ctx && ctx.state === 'suspended') ctx.resume();
}
app.js — detent ticks
inp.addEventListener('input', () => {
  set(k, inp.value, { user: true });
  if (!hasFx) placeBubble(r);   // no renderer frames to follow in the CSS fallback
  const det = Math.round(settings[k] / 5);
  if (det !== lastDetent) { lastDetent = det; if (soundOn()) tick(settings.volume / 100, k === 'volume' ? 1.1 : 0.9); }
});
Stage 5 · ripples and soundOpen ↗
The Volume bead dragged quickly to the left: it trails a lump along the thread, the thread ripples behind it and faint rings spread across the glass, with a 29% value bubble.

6 · Saved settings, and the finish

Last, the settings are saved. The key is versioned (dv56a.settings), older keys are wiped, and only values of the right type are taken.

app.js — load
function load() {
  let s = null;
  try {
    for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('dv56') && k !== KEY) localStorage.removeItem(k); }
    s = JSON.parse(localStorage.getItem(KEY) || 'null');
  } catch (e) { s = null; }
  const out = { ...DEFAULTS };
  if (s && typeof s === 'object') for (const k of Object.keys(DEFAULTS)) if (typeof s[k] === typeof DEFAULTS[k]) out[k] = s[k];
  return out;
}
app.js — save
function save() { clearTimeout(saveT); saveT = setTimeout(() => { try { localStorage.setItem(KEY, JSON.stringify(settings)); } catch (e) { /* private mode */ } }, 120); }

A returning visitor who chose the silver theme must not see a flash of graphite, so a small script in the head reads the saved theme before the first paint.

index.html — a script in the head: the theme before the first paint
// theme is the page's own (never the OS): read the saved settings before first paint so a returning visitor
// does not see a flash of the other theme. Older dv56 keys are wiped in app.js.
try { const s = JSON.parse(localStorage.getItem('dv56a.settings') || 'null');
  if (s && s.darkmode === false) document.documentElement.classList.add('theme-light');
  if (s && s.nightshift === true) document.documentElement.classList.add('night'); } catch (e) {}

Measured with the project's frame probe through clicks, drags, keys and slider moves at 120 Hz: no long tasks in any run, and no missed frames at 1440 × 900 or on a 390 × 844 phone (also with the CPU slowed four times) in the run on the quietest machine. The final build also has ?fxdpr=N to raise the chrome's resolution for stills, and window.__mercury for its tests.

app.js — the test hook
window.__mercury = {
  ready: ready.then(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))),
  fx, settings, set: (k, v, o) => set(k, v, o), hasFx,
  state: () => ({ settings: { ...settings }, fx: fx.state() }),
};
Stage 6 · the finished panelOpen ↗
The finished panel in the silver theme, driven from the keyboard: the Volume slider focused with a blue focus ring and a 75% value bubble under its chrome bead.
Stage 6 · on a phoneOpen ↗
The panel on a phone: a full-width settings list with the state word beside each chrome switch, and a finger dragging the Volume bead to the right, the thread rippling under a 93% value bubble.

Make it yours

  • Your settings are the rows in index.html and DEFAULTS in app.js: add a row with a data-key and the renderer picks up its groove. What a setting does lives in set().
  • Your studio: paint your own in env.js, or put a 2:1 photographic panorama in assets/env/ and name it in manifest.js.
  • The feel is a handful of numbers in _stepSwitch(): the head's spring (230), the tail's (62), the squash on impact, and the 0.12 s look-ahead of a flick in releaseSwitch().
  • Accessibility is already there: real buttons with role="switch", native range inputs, arrow keys, a skip link, a live region for the master switch, visible focus rings, 44 px targets, a CSS fallback without WebGL, and prefers-reduced-motion honoured.

The code is on GitHub at UlviShabanbayli/domvybe-mercury-toggles, under the MIT licence: use it in your own projects, commercial ones included. Mercury UI and Aura Design are made up. Everything on the page was made in code for this project: the chrome, the studio it reflects and the sounds.

The one idea worth keeping

Give the height field a local radius. A thin thread and a fat bead lifted with one radius either crease where they meet or flatten into a ribbon; lifted with the radius of whatever part is under the pixel, blended between parts, the mercury stays round everywhere.