Library / Tutorial

Marble radio,
step by step.

A pricing plan picker whose radio buttons are glass marbles. The chosen one rolls down its channel into a polished stone seat, overshoots and settles. The radio group stays real DOM; the stone and the glass are one WebGL shader. Three stages, each a live page you can run.

3Stages
1Canvas
0Libraries

This is the whole build of the marble radio, in the order it was written: the pricing step of a made-up boards app, Glasshouse. The three plans are three glass marbles with cat's-eye swirls on a polished white stone slab. Choosing one rolls its marble down a channel into a polished seat, where it overshoots a little, rocks and settles, while the one it replaces rolls back up into the rail. 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 three ES modules. The radio group is real DOM; the marbles are only pictures of it.

1 · A real radio group, and a stage in CSS

Start with the part a screen reader and a keyboard use: a radiogroup of three buttons with role="radio", labelled by the eyebrow and described by a hint. The marbles get invisible hit circles that point at the same plans, so a click on the glass chooses too.

index.html — the radio group
<div class="plans" id="plans" role="radiogroup" aria-labelledby="plans-title" aria-describedby="plans-hint">
  <button class="opt" role="radio" aria-checked="false" data-i="0" id="opt-0"><b>Solo</b><span>$9 · 1 seat</span></button>
  <button class="opt" role="radio" aria-checked="true" data-i="1" id="opt-1"><b>Studio</b><span>$24 · up to 10 seats</span></button>
  <button class="opt" role="radio" aria-checked="false" data-i="2" id="opt-2"><b>Team</b><span>$59 · unlimited seats</span></button>
</div>
index.html — the marbles as pointer aliases
<div class="hits" id="hits" aria-hidden="true"><i data-i="0"></i><i data-i="1"></i><i data-i="2"></i></div>

Only the checked radio is a tab stop (a roving tabindex), and every radio says its price and seats.

app.js — keep the DOM in step
function syncDom(focus) {
  els.opts.forEach((o, i) => {
    o.setAttribute('aria-checked', String(i === sel));
    o.tabIndex = i === sel ? 0 : -1;
    if (disabled.has(i)) o.setAttribute('aria-disabled', 'true'); else o.removeAttribute('aria-disabled');
    o.setAttribute('aria-label', `${PLANS[i].n}, $${PLANS[i].p} per month, ${PLANS[i].d}`);
  });
  els.hits.forEach((h, i) => h.classList.toggle('off', disabled.has(i)));
  if (focus) els.opts[sel].focus();
  if (phase === 'idle') els.goLbl.textContent = `Continue with ${PLANS[sel].n}`;
}

The arrow keys move the choice and the focus together, wrap at the ends and skip a disabled plan; Space and Enter check the focused one.

app.js — the keyboard
o.addEventListener('keydown', (e) => {
  const dir = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
  if (dir) {
    e.preventDefault(); if (locked()) return;
    let j = sel; for (let k = 0; k < 3; k++) { j = (j + dir + 3) % 3; if (!disabled.has(j)) break; }
    focusIdx = j; select(j, true);
  } else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); select(i, true); }
});
app.js — choose a plan
function select(i, focus = false) {
  if (locked() || disabled.has(i)) return;
  if (i === sel) { if (focus) els.opts[i].focus(); return; }
  sel = i;
  priceFrom = Math.round(curPrice()); priceTo = PLANS[i].p; priceT = reduced ? 1 : 0;
  if (reduced) st.m.forEach((m, j) => { m.z = j === sel ? ZS : ZR; m.vz = 0; m.y = restY(m.z); });
  syncDom(focus); wake();
}

The stage is a slab in world units: a rail at the back, three channels and three seats. One small camera turns a world point into page pixels, so the slab, the marbles and the labels under each column all come from the same numbers, on any screen.

scene.js — world to page
project(P) {
  const { f, r, u } = this.basis, d = sub(P, this.cam.ro), z = dot(d, f);
  return [this.cam.pp[0] + dot(d, r) / z * this.cam.fl, this.cam.pp[1] - dot(d, u) / z * this.cam.fl, z];
}
app.js — a label under each marble
els.opts.forEach((o, i) => { labelX[i] = view.project([XS[i], -SB[1], SB[2]])[0]; o.style.left = labelX[i] + 'px'; o.style.top = labTop + 'px'; });

A ball resting on a groove does not sit at a fixed height: it rises out of the rail and drops into the seat. The resting height is precomputed from the groove's own height field, as the highest support under the ball's disk.

scene.js — where a ball rests
// centre height of a resting ball over (x, z): the highest support under its disk (no penetration, real contacts)
const REST = (() => {
  const z0 = ZR - 0.25, z1 = ZS + 0.25, N = 260, out = new Float32Array(N + 1), G = 26;
  for (let k = 0; k <= N; k++) {
    const z = z0 + (z1 - z0) * k / N; let best = -1;
    for (let a = -G; a <= G; a++) for (let b = -G; b <= G; b++) {
      const dx = a / G * R * 0.98, dz = b / G * R * 0.98, rr = dx * dx + dz * dz; if (rr > R * R * 0.96) continue;
      const y = hgt(dx, z + dz) + Math.sqrt(R * R - rr); if (y > best) best = y;
    }
    out[k] = best;
  }
  return { z0, z1, N, out };
})();

The roll is a heavy spring along the channel whose damping falls as the marble nears its target, so it arrives, overshoots a little and rocks before it settles. The rotation is the distance over the radius: it really rolls.

app.js — the heavy spring
// heavy glass: a spring whose damping falls as it nears the seat, so it arrives, overshoots a little and rocks
  const h = 1 / 480; let tt = dt;
  while (tt > 1e-6) {
    const s = Math.min(h, tt); tt -= s;
    const d = zT - m.z, zeta = ZL + (ZH - ZL) * sstep(0.03, 0.22, Math.abs(d));
    m.vz += (K * d - 2 * zeta * Math.sqrt(K) * m.vz) * s;
    m.vz = Math.max(-VMAX, Math.min(VMAX, m.vz));
    const dz = m.vz * s; m.z += dz; m.roll += dz / R;
  }
  if (Math.abs(zT - m.z) < 0.003 && Math.abs(m.vz) < 0.03) { m.roll += (zT - m.z) / R; m.z = zT; m.vz = 0; }
}
m.y = restY(m.z);

Here the slab and the glass are CSS: projected polygons for the stone, radial and conic gradients for the swirls.

marble.css — a CSS marble
.cs-marble.m1 { background: radial-gradient(circle at 40% 35%, rgba(240, 248, 255, .55), rgba(120, 175, 255, .35) 45%, rgba(20, 80, 210, .55) 80%, rgba(170, 210, 255, .7)); }
.cs-marble.m1::before { background: conic-gradient(from -30deg, #1f6dff 0 22%, transparent 22% 50%, #3d8cff 50% 72%, transparent 72%); opacity: .68; }
app.js — place the CSS marbles
function moveFallback() {
  st.m.forEach((m, i) => {
    const c = view.project([m.x, m.y, m.z]), r = Math.abs(view.project([m.x + R, m.y, m.z])[0] - c[0]);
    const el = els.csMarbles[i];
    Object.assign(el.style, { left: c[0] + 'px', top: c[1] + 'px', width: 2 * r + 'px', height: 2 * r + 'px', transform: `translate(-50%,-50%) rotate(${(m.roll * 57.3).toFixed(1)}deg)` });
    el.classList.toggle('off', m.dis > 0.5);
  });
}

Continue is a real flow: a busy state that locks the group, an announcement, and a way back.

app.js — Continue
els.go.addEventListener('click', () => {
  if (phase !== 'idle') return;
  phase = 'confirming';
  els.plans.setAttribute('aria-disabled', 'true');
  els.go.setAttribute('aria-busy', 'true'); els.go.setAttribute('aria-disabled', 'true');
  els.goLbl.textContent = 'Reserving…'; els.status.textContent = '';
  wake();
  setTimeout(() => {
    phase = 'done';
    els.go.removeAttribute('aria-busy'); els.go.classList.add('is-done');
    els.goLbl.textContent = `${PLANS[sel].n} is yours`;
    els.status.textContent = `${PLANS[sel].n} plan reserved. Your 14-day trial has started.`;
    els.undo.hidden = false;
    st.bloom = reduced ? 0.4 : 1.0; wake();
  }, reduced ? 300 : 900);
});

The loop runs only while something moves, then sleeps.

app.js — one frame
function frame(now) {
  raf = 0;
  const dt = Math.min(0.05, last ? (now - last) / 1000 : 1 / 120); last = now;
  const moving = step(dt); lastMoving = moving;
  moveFallback();
  placeHits();
  if (moving) raf = requestAnimationFrame(frame); else last = 0;
}
Stage 1 · the radio group and a CSS stageOpen ↗
The Glasshouse pricing page: Pick a plan. Let it roll. above a pale CSS slab with three CSS marbles, amber, blue and jade; Team has just been checked and its jade marble starts down its channel while the blue one leaves the seat.

2 · Stone and glass in WebGL

One canvas goes under the page. It is drawn by one full-screen fragment shader that traces a ray per pixel.

index.html — the canvas
<canvas class="fx" id="fx" aria-hidden="true"></canvas>
gl.js — a context that keeps its pixels
export function createGL(canvas, { alpha = false } = {}) {
  const opts = { alpha, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' };
  const gl = canvas.getContext('webgl2', opts) || canvas.getContext('webgl', opts);
  if (!gl) return null;
  const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  return gl;
}

The slab is a box, and its top is a height field: the rail, the channels and the seats are smooth bowls carved out of it. The same function, written in JavaScript in stage 1, gives the resting heights, so the glass never sinks into the stone.

scene.js — the groove
float hgt(vec2 p){
  float h=0.0;
  float d=abs(p.y-ZR); if(abs(p.x)>2.05) d=length(vec2(abs(p.x)-2.05, p.y-ZR));
  if(d<GW) h=min(h,-GD*(1.0-d*d/(GW*GW)));
  for(int i=0;i<3;i++){
    float x=-1.6+1.6*float(i);
    float r=length(p-vec2(x,ZS));
    float sr=GW+0.01;
    if(r<sr) h=min(h,-(GD+0.012)*(1.0-r*r/(sr*sr)));
    float dx=abs(p.x-x);
    if(dx<0.24 && p.y>ZR && p.y<ZS) h=min(h,-0.07*(1.0-dx*dx/0.0576));
  }
  return h;
}

The marbles are analytic spheres. Where a ray hits one, the glass reflects the room by Fresnel and refracts the stone behind it.

scene.js — a ray meets a sphere
float sphere(vec3 ro, vec3 rd, vec3 c){ vec3 oc=ro-c; float b=dot(oc,rd); float cc=dot(oc,oc)-R*R; float h=b*b-cc; if(h<0.0) return -1.0; return -b-sqrt(h); }
scene.js — reflect and refract
float F=0.04+0.96*pow(1.0-cv,5.0);
vec3 r=reflect(rd,n);
vec3 refl=r.y<-0.02 ? traceScene(P+r*0.003, r, false) : studio(r,0.0);
vec3 d1=refract(rd,n,1.0/1.5);

Inside each marble the ray is marched only through its core, where the cat's-eye vanes are: three twisted blades that turn with the marble's roll.

scene.js — the cat's-eye
float vane(vec3 q){
  float rho=length(q.xz);
  float phi=atan(q.z,q.x)-2.4*q.y/R;
  float d=rho*abs(sin(phi*1.5))/1.5;
  float th=0.004+0.024*rho;
  float ext=smoothstep(0.37,0.3,length(q))*smoothstep(0.0,0.02,rho);
  return smoothstep(th,th*0.55,d)*ext;
}

A glass ball is a lens. On the stone, each marble throws a soft shadow and, inside it, a focused spot of its own colour; the chosen marble's spot is brighter, and a confirmed plan floods its seat.

scene.js — the coloured caustic
float f=along/(1.5*R);
float rc=R*max(abs(1.0-f),0.12)*0.9;
float g=uM[i].w, dis=uS[i].y;
float boost=1.0+0.35*g+uBloom*g*2.6;
vec3 tint=tintOf(i);
float caus=exp(-pow(d/rc,2.0))*(0.6/(rc/R+0.15));
float ring=exp(-pow((d-rc)/(rc*0.25+0.01),2.0))*0.5;
vec3 through=mix(vec3(1.0),tint,0.8)*(caus+ring)*1.5*boost*(1.0-0.75*dis)+tint*0.08;

Tracing every pixel every frame would be wasteful, so the page sleeps at rest and a frame redraws only the screen boxes of the marbles that changed: their glass, shadow, caustic and reflection. The canvas keeps its drawing buffer, so the rest of the picture stays.

app.js — what changed
for (const i of full) rects.push(union(view.marbleBox(prev[i]), view.marbleBox(cur[i])));
for (const i of spin) rects.push(view.marbleBox(cur[i], false));
if (!rects.length) return rects;
// a still marble whose glass overlaps a dirty box refracts what changed: redraw its glass too
for (let pass = 0; pass < 2; pass++) cur.forEach((m, i) => {
  if (full.includes(i)) return;
  const g = view.marbleBox(m, false);
  if (rects.some((r) => overlap(r, g))) rects.push(g);
});
scene.js — redraw only those boxes
for (const r of rects) {
  const x0 = Math.max(0, Math.floor(r[0] * dpr)), x1 = Math.min(c.width, Math.ceil(r[2] * dpr));
  const y0 = Math.max(0, Math.floor(r[1] * dpr)), y1 = Math.min(c.height, Math.ceil(r[3] * dpr));
  if (x1 <= x0 || y1 <= y0) continue;
  gl.scissor(x0, c.height - y1, x1 - x0, y1 - y0); p.draw(); this.px += (x1 - x0) * (y1 - y0);
}

At rest the seated marble turns very slowly; only its glass is redrawn, about 30 times a second.

app.js — the idle spin
const idleSpin = () => !reduced && visible && phase !== 'confirming' && st.m[sel].z === ZS && st.m[sel].vz === 0;

Without WebGL the stage 1 CSS stage takes over, with the same layout and the same physics.

app.js — WebGL, or the CSS stage
try { scene = new Scene(els.fx); } catch (e) { scene = null; document.documentElement.classList.add('no-fx'); }
Stage 2 · stone and glass in WebGLOpen ↗
The pricing page with the WebGL stage: a polished white stone slab, the amber marble in the rail, the blue one rolling back up its channel and the jade one dropping into the polished front seat, coloured caustics on the stone.

3 · The finish

The chosen plan is kept under one versioned key, and older keys of the component are wiped before the first paint.

index.html — wipe old keys
// storage: versioned keys only; anything older is wiped. The page's theme is its own (dark) and never follows the OS.
try {
  for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && /^dv89/.test(k) && k !== 'dv89.plan') localStorage.removeItem(k); }
} catch (e) {}
app.js — read the plan
let saved = -1; try { saved = PLANS.findIndex((p) => p.key === localStorage.getItem(KEY)); } catch (e) {}
let sel = saved >= 0 ? saved : 1;
app.js — keep the plan
try { localStorage.setItem(KEY, PLANS[i].key); } catch (e) {}

A lost WebGL context drops to the CSS stage instead of a blank one, and a small test hook reports the state.

app.js — a lost context
els.fx.addEventListener('webglcontextlost', (e) => { e.preventDefault(); document.documentElement.classList.add('no-fx'); scene = null; layout(); });
app.js — the test hook
window.__marble = {
  ready, PLANS,
  get sel() { return sel; }, get phase() { return phase; },
  get busy() { return raf !== 0 && lastMoving; },
Stage 3 · the finished pickerOpen ↗
The finished picker: three glass marbles with cat's-eye swirls on the stone slab, the jade Team marble dropping into the seat, Team checked and $59 a month.
Stage 3 · on a phoneOpen ↗
The picker on a phone: Pick a plan. Let it roll. above the full-width stone slab with three marbles, Studio seated, the labels under each, $24 a month and a full-width Continue with Studio.

Make it yours

  • Your plans are the three buttons in index.html and the PLANS list in app.js (name, price, seats).
  • Your glass is in scene.js: vaneCol sets each swirl's colours and tintOf the colour of its caustic.
  • Your weight is the spring: K is the stiffness, ZL and ZH the damping near and far from the seat. A lighter marble rolls faster and rocks more.
  • Accessibility is already there: native buttons with role="radio", a roving tabindex, arrow keys that wrap and skip a disabled plan, a visible focus (and a ring of light in the stone), a live status line, 44 px targets, prefers-reduced-motion (the marbles jump into place and nothing spins) and the CSS stage without WebGL.

Glasshouse is made up, and Continue reserves nothing. Everything on the page was made in code for this project: the stone and the glass are rendered by the shader, the fallback is CSS.

The one idea worth keeping

Keep the control real and let the picture follow it. The radio group is ordinary DOM that works without the canvas; the marbles only show what it already says, so the glass can be as heavy as you like.