This is the whole build of the marble pricing cards, in the order it was written: the pricing section of a made-up CMS for architecture studios, Plinth. Three plans, each a slab of polished black Nero Marquina with white calcite veins and one bold gold vein. The chosen plan's gold lights up, white-hot with an amber halo through the stone, and a raking light slides across the polish. 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 stone is one transparent WebGL canvas under the cards; every word (names, prices, features, buttons) stays real DOM above it, and the plans are native radio buttons.
1 · The page, and slabs in CSS
Each plan comes from one template: a hidden native radio, a label that covers the whole slab, the type, and the button. The radios make the three slabs a real radiogroup, so arrows, Tab and screen readers work without extra code.
<template id="tpl-plan">
<article class="plan">
<input class="radio" type="radio" name="plan">
<label class="hit"></label>
<div class="face">
<p class="tag">Most chosen</p>
<h2 class="name"></h2>
<p class="sub"></p>
<p class="price"><span class="cur">$</span><span class="num"></span><span class="per">/ mo</span></p>
<p class="billed"></p>
<i class="rule" aria-hidden="true"></i>
<ul class="feats"></ul>
</div>
<button class="cta" type="button"><span class="spin" aria-hidden="true"></span><svg class="tick" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 10 17.5 19 7"/></svg><span class="lbl"></span></button>
<p class="err" role="alert"></p>
</article>
</template>
Billing is two radios too, with a thumb that slides under the checked one.
<fieldset class="bill" id="bill">
<legend class="sr">Billing period</legend>
<i class="thumb" aria-hidden="true"></i>
<label><input type="radio" name="billing" value="monthly"><span>Monthly</span></label>
<label><input type="radio" name="billing" value="yearly" checked><span>Yearly <b>−20%</b></span></label>
</fieldset>
The first slabs are frosted dark glass with a gold hairline vein drawn as an inline SVG. They stay in the finished page as the fallback without WebGL.
.no-fx .plan { background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 344 516' preserveAspectRatio='none'%3E%3Cpath d='M-10 470 C 60 430 90 380 150 340 S 250 250 290 170 S 340 60 360 30' fill='none' stroke='%23d9b56e' stroke-width='2.2' opacity='.85'/%3E%3Cpath d='M-10 200 C 40 220 80 190 120 210 S 200 280 230 260' fill='none' stroke='%23d8d2c6' stroke-width='.9' opacity='.35'/%3E%3Cpath d='M120 -10 C 140 60 110 120 160 170 S 260 220 360 230' fill='none' stroke='%23d8d2c6' stroke-width='.7' opacity='.25'/%3E%3C/svg%3E") center / 100% 100% no-repeat,
radial-gradient(120% 80% at 20% 10%, rgba(255, 255, 255, .07), transparent 60%),
linear-gradient(160deg, rgba(34, 32, 30, .92), rgba(8, 8, 8, .94));
box-shadow: 0 0 0 1px rgba(236, 228, 214, .12) inset, 0 24px 50px rgba(0, 0, 0, .55); }
A price is a row of digit columns, 0 to 9 stacked. Changing the price moves each column to its digit with a small delay per column, so the number rolls like an odometer instead of blinking.
.dg { display: inline-block; position: relative; height: calc(112 * var(--k)); overflow: hidden; vertical-align: top; }
.dg > span { position: absolute; left: 50%; top: 0; display: flex; flex-direction: column; align-items: center; width: max-content; transform: translate(-50%, 0); transition: transform .75s var(--ease); }
.dg > span > span { display: block; height: calc(112 * var(--k)); padding: 0 .06em; }
function setPrice(c, animate) {
const v = String(st.yearly ? c.p.y : c.p.m), el = c.num;
const prev = el.dataset.v || '';
const col = () => `<span>${[...'0123456789'].map((d) => `<span>${d}</span>`).join('')}</span>`;
if (el.children.length !== v.length) {
el.innerHTML = [...v].map(() => `<span class="dg">${col()}</span>`).join('');
// start each column on the digit it replaces (aligned from the right) so the roll reads as a change
const pv = prev.padStart(v.length, '0').slice(-v.length);
[...el.children].forEach((dg, j) => { dg.firstChild.style.transform = `translate(-50%, ${-pv[j] * 10}%)`; dg.style.width = digitW(pv[j]) + 'em'; });
void el.offsetWidth;
}
if (!animate) [...el.children].forEach((dg) => { dg.style.transition = 'none'; dg.firstChild.style.transition = 'none'; });
[...el.children].forEach((dg, j) => {
dg.firstChild.style.transitionDelay = animate ? (j * 70) + 'ms' : '';
dg.firstChild.style.transform = `translate(-50%, ${-v[j] * 10}%)`;
dg.style.width = digitW(v[j]) + 'em';
});
if (!animate) { void el.offsetWidth; [...el.children].forEach((dg) => { dg.style.transition = ''; dg.firstChild.style.transition = ''; }); }
el.dataset.v = v;
el.setAttribute('aria-label', `$${v} per month`);
$('.cur', c.el).setAttribute('aria-hidden', 'true');
c.billed.textContent = st.yearly ? `billed yearly · $${(st.yearly ? c.p.y : c.p.m) * 12}` : 'billed monthly';
}
function select(i, { announce = true } = {}) {
if (st.busy || st.done) { cards[st.sel].radio.checked = true; return; }
const prev = st.sel;
st.sel = i;
cards.forEach((c, k) => { c.radio.checked = k === i; c.el.classList.toggle('on', k === i); });
updateCTAs();
updateNavBtns();
if (announce && prev !== i) say(`${PLANS[i].name} plan selected: $${price(i)} a month, ${st.yearly ? 'billed yearly' : 'billed monthly'}.${st.mode === 'tall' ? ` ${i + 1} of 3.` : ''}`);
poke();
}
Stone should feel heavy. A hover raises the slab a few pixels and a press sinks it, both on a stiff, damped spring.
// lift: hover raises the slab, a press sinks it; a heavy spring so stone feels like stone
const goal = reduce ? 0 : (st.press[i] ? -3 : st.hovGoal[i] * 6);
const a = (goal - st.lift[i]) * 320 - st.liftV[i] * 26;
st.liftV[i] += a * dt; st.lift[i] += st.liftV[i] * dt;
if (Math.abs(st.lift[i] - goal) < 0.01 && Math.abs(st.liftV[i]) < 0.05) { st.lift[i] = goal; st.liftV[i] = 0; } else moving = true;
On a phone the slabs sit in a row and the centred one is the chosen plan. The row follows the finger with a rubber band at the ends; on release the flick's speed decides where it lands, and the row glides there on a spring.
const endDrag = (e) => {
const d = st.drag; st.drag = null;
if (!d || !d.on) return;
st.dragMoved = performance.now();
const target = Math.max(0, Math.min(2, Math.round(st.pos - d.vx * 0.32 * (1000 / L.pitch) * 0.5)));
st.posV = -d.vx * 1000 / L.pitch;
if (target !== st.sel) select(target);
poke();
};
// the phone row glides to the chosen slab
if (st.mode === 'tall' && !st.drag) {
const goal = st.sel;
if (reduce) { st.pos = goal; st.posV = 0; }
const a = (goal - st.pos) * 140 - st.posV * 22;
st.posV += a * dt; st.pos += st.posV * dt;
if (Math.abs(st.pos - goal) < 1e-3 && Math.abs(st.posV) < 1e-3) { st.pos = goal; st.posV = 0; } else moving = true;
function onCTA(i, e) {
const c = cards[i];
if (st.busy || st.done) return;
if (i !== st.sel) { select(i); return; }
c.err.textContent = '';
st.busy = true; lock(true);
c.cta.setAttribute('aria-busy', 'true'); c.lbl.textContent = 'Starting trial…';
plansEl.setAttribute('aria-busy', 'true');
setTimeout(() => {
st.busy = false; c.cta.removeAttribute('aria-busy'); plansEl.removeAttribute('aria-busy');
st.done = true;
c.cta.classList.add('is-done'); c.lbl.textContent = `Trial started · ${c.p.name}`;
statusEl.innerHTML = `Your 14-day ${c.p.name} trial has started. <button type="button" id="undo">Change plan</button>`;
$('#undo').addEventListener('click', undo);
updateNavBtns();
poke();
}, NET);
}
Nothing runs when nothing moves: the loop asks for the next frame only while a spring is still travelling.
function tick(now) {
raf = 0;
const dt = lastT ? Math.min(0.05, (now - lastT) / 1000) : 1 / 60;
lastT = now;
const moving = step(dt);
applyDOM(false);
if (moving) raf = requestAnimationFrame(tick);
}

2 · The marble
The stone is expensive to compute: a seven-octave warped noise for the smoky clouds, a contour of it for the white calcite vein, ridged hairlines, and the gold. None of that changes from frame to frame, so it is baked once per layout into a texture per slab: calcite in red, gold in green, cloud in blue, the halo around the gold in alpha.
// the main calcite vein: one contour of a strongly warped field, broken by noise, with a soft bloom under the polish
float n=fbm(q*0.9+w*2.6+w2*0.35);
float f1=abs(n-0.47);
float main=exp(-pow(f1/0.0055,2.0))*smoothstep(0.38,0.6,fbm3(q*1.7+w2))+exp(-pow(f1/0.02,2.0))*0.09;
// gold: a bold vein that crosses the slab diagonally (plus a thin branch), wandering and tapering
float sl=mod(seed,2.0)<1.0?0.75:-0.85;
float gl=q0.y*0.75+q0.x*sl+(w.x-0.5)*1.5+(fbm3(q*2.2+w)-0.5)*0.35+(w2.y-0.5)*0.22+(fbm3(q*6.0)-0.5)*0.05+0.12;
float gw=0.006+0.018*smoothstep(0.2,0.8,fbm3(q*0.9+seed*1.3));
float px=2.0*1.15/uSize.y; // one texel in slab units: keeps the thin gold crisp but unaliased
function bakeNext() {
if (!fx || L.baked >= 3) return false;
const i = L.baked, c = L.cards[i];
fx.bake(i, Math.round(c.w * L.dpr), Math.round(c.h * L.dpr), c.w / c.h, PLANS[i].seed);
L.baked++;
return true;
}
Every frame then only samples the bake and lights it, all three slabs in one draw. The polish reflects a diagonal soft box: one broad lobe and two crisp strips, positioned by the light.
// the raking light: a diagonal soft box reflected in the polish (one broad lobe + two crisp strips)
vec2 rl=p-uLight;
vec2 dir=vec2(0.8638,-0.5038);
float along=dot(rl, vec2(0.5038,0.8638));
float band=exp(-pow(along/(150.0*s),2.0));
float len=smoothstep(1.3,0.4,abs(dot(rl,dir))/(hb.y*1.7));
float aa=1.2;
float bandS=(smoothstep(-58.0*s-aa,-58.0*s+aa,along)-smoothstep(-5.0*s-aa,-5.0*s+aa,along)
+0.6*(smoothstep(9.0*s-aa,9.0*s+aa,along)-smoothstep(23.0*s-aa,23.0*s+aa,along)))*len;
float broad=0.5+0.5*smoothstep(1.4*hb.y,-1.0*hb.y, length(lp-vec2(-0.6*hb.x,-0.9*hb.y)));
The chosen slab's gold glows from an origin outward. A front runs along the veins as its radius grows, and behind it the gold stays lit.
float dist=length(q-ign.xy);
float reach=smoothstep(ign.z, ign.z-0.35, dist);
float front=exp(-pow((dist-ign.z+0.1)/0.14,2.0))*step(ign.z,3.4);
vec3 goldM=vec3(0.95,0.68,0.3);
vec3 goldCol=goldM*(0.18+0.55*max(lit,0.0)+1.6*band+2.4*bandS+0.25*hov);
goldCol+=vec3(1.0,0.5,0.12)*glow*(2.4+3.6*flow) + vec3(1.0,0.8,0.5)*glow*2.5*smoothstep(0.7,1.0,flow);
body=mix(body, goldCol, gmask);
body+=vec3(1.0,0.42,0.08)*glow*0.8*halo*(0.5+0.5*flow); // light glowing through the stone around the gold
The origin is where you pressed. The press point is turned into slab space on pointerdown; the keyboard lights from the vein's end, a swipe from the edge the slab arrives from.
hit.addEventListener('pointerdown', (e) => {
const [x, y] = slabPoint(i, e.clientX, e.clientY);
lastPress = { i, x, y, t: performance.now() };
if (!st.busy && !st.done) st.press[i] = 1;
poke();
});
if (prev !== i || origin) {
const c = L.cards[i] || { w: 1, h: 1 };
const asp = c.w / c.h || 0.65;
const o = origin || [asp * 1.15, -1.15];
st.ign[i] = { x: o[0], y: o[1], r: reduce ? 3.4 : 0 };
if (reduce) { st.glow = st.glow.map((g, k) => (k === i ? 1 : 0)); }
}
const ig = st.ign[i];
if (ig.r < 3.4) { ig.r = Math.min(3.4, ig.r + dt * (1.1 + ig.r * 1.6)); moving = true; }
At rest the light drifts slowly across the slabs; when the mouse is over the page it follows the pointer.
function restLight(t) {
const k = L.k;
if (st.mode === 'tall') return [L.stage.w * 0.5 + Math.sin(t * 0.35) * 150 * k, 46 * k];
return [564 * k + Math.sin(t * 0.35) * 520 * k, 36 * k];
}
stage.addEventListener('pointermove', (e) => {
const d = st.drag;
if (!d || e.pointerId !== d.id) return;
const dx = e.clientX - d.x0, dy = e.clientY - d.y0;
if (!d.on) {
if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy) * 1.2 && !st.busy && !st.done) { d.on = true; try { stage.setPointerCapture(e.pointerId); } catch (er) { /* */ } cards.forEach((c, k) => { st.press[k] = 0; }); }
else if (Math.abs(dy) > 10) { st.drag = null; return; }
else return;
}
const now = performance.now(), dt = Math.max(1, now - d.lt);
d.vx = 0.7 * d.vx + 0.3 * ((e.clientX - d.lx) / dt); d.lx = e.clientX; d.lt = now;
let p = d.pos0 - dx / L.pitch;
if (p < 0) p = -Math.pow(-p, 0.7) * 0.35; else if (p > 2) p = 2 + Math.pow(p - 2, 0.7) * 0.35;
st.pos = p; st.posV = 0;
st.lightGoal = [e.clientX - (L.stage.docX - scrollX), e.clientY - (L.stage.docY - scrollY)];
poke();
});
const endDrag = (e) => {
const d = st.drag; st.drag = null;
if (!d || !d.on) return;
st.dragMoved = performance.now();
const target = Math.max(0, Math.min(2, Math.round(st.pos - d.vx * 0.32 * (1000 / L.pitch) * 0.5)));
st.posV = -d.vx * 1000 / L.pitch;
if (target !== st.sel) select(target, edgeOrigin(target, target > st.sel ? -1 : 1));
poke();
};
stage.addEventListener('pointerup', endDrag); stage.addEventListener('pointercancel', endDrag);
// ---------------- pointer light ----------------
addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse' && !st.drag) return;
st.lightGoal = [e.clientX - (L.stage.docX - scrollX), e.clientY - (L.stage.docY - scrollY)];
st.pointerIn = true; poke();
}, { passive: true });
Most frames only move the light. Then only the three slab faces are repainted, and the shadows and glows already in the canvas are kept.
// only the light moved (the commonest frame): repaint just the slab faces, the shadows and glows are unchanged
const sig = slabs.map((k) => [k.x, k.y, k.sel.toFixed(4), k.rim.toFixed(4), k.lift.toFixed(3), k.dim].join(',')).join('|') + '|' + L.fx.w + 'x' + L.fx.h + '|' + d;
const full = sig !== lastSig || forceFull; lastSig = sig; forceFull = false;
const boxes = full ? null : slabs.filter((k) => k.x + k.hw > 0 && k.x - k.hw < L.fx.w * d).map((k) => [k.x - k.hw - 1, k.y - k.hh - 1, k.x + k.hw + 1, k.y + k.hh + 1]);
Full rate while something moves, the idle drift at 30 frames a second, then the drift eases to a stop and the loop sleeps: zero draws.
function tick(now) {
raf = 0;
const dt = lastT ? Math.min(0.05, (now - lastT) / 1000) : 1 / 60;
lastT = now;
if (fx && L.baked < 3) {
forceFull = true;
// bake one slab per frame so a resize never blocks a frame for long
bakeNext();
raf = requestAnimationFrame(tick); return;
}
const { moving, drift } = step(dt, now);
const due = moving || !drift || now - lastDraw > 31; // idle drift alone renders at ~30 fps
if (due) { applyDOM(false); render(); lastDraw = now; S.draws++; frames++; }
if (moving || drift) raf = requestAnimationFrame(tick);
else S.awake = false;
}
Without WebGL, with ?nofx or after a lost context, the CSS slabs from stage 1 take over.
function startFX() {
if (root.classList.contains('no-fx')) return null;
try { return new Marble(fxCanvas); } catch (e) { root.classList.add('no-fx'); return null; }
}

3 · The finish
A first visit opens with a little ceremony: the light rakes in from the left and the chosen slab's vein ignites from its corner, end to end.
function beginIntro() {
st.introDone = true;
if (reduce) { st.glow[st.sel] = 1; st.ign[st.sel].r = 3.4; st.spillO = 1; return; }
st.intro = true; st.introT = 0; st.spillO = 0;
const c = L.cards[st.sel];
st.ign[st.sel] = { x: (c.w / c.h) * 1.15, y: -1.15, r: 0 };
st.lastInput = performance.now();
}
if (st.intro) {
st.introT += dt;
const e = Math.min(1, st.introT / 1.5), ee = 1 - Math.pow(1 - e, 3);
const from = [-0.35 * L.stage.w, goal[1]];
goal = [from[0] + (goal[0] - from[0]) * ee, goal[1]];
st.light = goal.slice();
if (e >= 1) st.intro = false;
moving = true;
Starting the trial rolls a ring of light up through the slab's veins.
st.flare = { r: 0, a: reduce ? 0 : 1, i, x: 0, y: 1.05 };
if (st.flare.a > 0) { st.flare.r += dt * 2.6; if (st.flare.r > 3.6) { st.flare.a = 0; st.flare.r = 0; } moving = true; }
float fl=flare*exp(-pow((length(q-uFlare.xy)-uFlare.z)/0.22,2.0))*uFlare.w;
The plan and the billing are stored under versioned keys; older keys of the component are wiped before the first paint.
const store = {
get(k) { try { return localStorage.getItem(KEY + k); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem(KEY + k, v); } catch (e) { /* private mode */ } },
};
?current=atelier marks the plan you already have: its button says "Current plan", is
aria-disabled, and the stone dulls. ?fail shows the error state once.
if (isCur) { c.lbl.textContent = 'Current plan'; c.cta.setAttribute('aria-disabled', 'true'); }
if (st.failNext) {
st.failNext = false; lock(false);
c.lbl.textContent = 'Try again';
c.err.textContent = "Couldn't start the trial. Check your connection and try again.";
c.cta.classList.remove('shake'); void c.cta.offsetWidth; if (!reduce) c.cta.classList.add('shake');
return;
}


Make it yours
- Your plans are
PLANSinapp.js: names, monthly and yearly prices, features and aseedthat gives each slab its own stone. - Your stone is the bake in
stone.js: the base colour of the body, the calcite colour, and the width of the gold vein. A lighter base and grey veins give Carrara; green clouds give a verde. - Accessibility is already there: native radios for the plans and the billing, a visible focus ring, an
announcer for the choice, 44 px targets on touch, prev / next buttons for the phone row, every word in the DOM,
prefers-reduced-motion(no intro, no drift, no roll) and a CSS fallback without WebGL.
Plinth is a made-up brand. Everything on the page was made in code for this project: the marble, the gold and the light are rendered by the shader, the fallback is CSS.
Bake what does not move. The marble is computed once per layout; a frame only samples it and adds light, so a polished, glowing slab costs a couple of milliseconds, and nothing at all once the loop sleeps.