Library / Tutorial

Marble pricing,
step by step.

Pricing cards cut from polished black marble. Choose a plan and its gold veins ignite from your press; a raking light follows the pointer and the prices roll like an odometer. The stone is baked once and lit every frame; every word stays DOM. Three stages, each a live page you can run.

3Stages
1Canvas
0Libraries

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.

index.html — one plan
<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.

index.html — Monthly or Yearly
<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.

marble.css — the CSS slab
.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.

marble.css — a digit column
.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; }
app.js — roll the price
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';
}
app.js — choose a plan
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.

app.js — the slab 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.

app.js — let go of a swipe
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();
};
app.js — the row glides home
// 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;
app.js — start the trial
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.

app.js — the loop
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);
}
Stage 1 · the page and CSS slabsOpen ↗
The Plinth pricing page: the headline Pricing, set in stone, the Monthly / Yearly toggle and three frosted dark slabs with a gold hairline; Atelier is chosen, framed in gold and lifted under the pointer.

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.

stone.js — the calcite vein (bake)
// 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;
stone.js — the gold vein (bake)
// 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
app.js — one slab per frame
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.

stone.js — the raking 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.

stone.js — the ignition front
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);
stone.js — metal that catches the light, and the glow
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.

app.js — remember the press
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();
});
app.js — ignite from it
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)); }
}
app.js — the front accelerates as it grows
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.

app.js — the resting path
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];
}
app.js — follow the pointer
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.

app.js — repaint only the faces
// 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.

app.js — one frame
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.

app.js — WebGL, or the CSS slabs
function startFX() {
  if (root.classList.contains('no-fx')) return null;
  try { return new Marble(fxCanvas); } catch (e) { root.classList.add('no-fx'); return null; }
}
Stage 2 · the marbleOpen ↗
Three slabs of polished black marble with white calcite veins and gold veins; Atelier has just been chosen and its gold veins are igniting outward, white-hot with an amber halo, while Practice cools; a raking light lies across the polish.

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.

app.js — the intro
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();
}
app.js — the light rakes in
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.

app.js — the trial started
st.flare = { r: 0, a: reduce ? 0 : 1, i, x: 0, y: 1.05 };
app.js — the ring grows
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; }
stone.js — the flare in the gold
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.

app.js — storage
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.

app.js — the current plan
if (isCur) { c.lbl.textContent = 'Current plan'; c.cta.setAttribute('aria-disabled', 'true'); }
app.js — the error state
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;
}
Stage 3 · the finished cardsOpen ↗
The finished cards: Atelier chosen, its gold veins igniting from the press with an amber halo, the gold frame and the amber spill on the page, the raking light on the stone.
Stage 3 · on a phoneOpen ↗
The cards on a phone: the headline and the toggle above one centred marble slab; a swipe has just brought Atelier to the centre and its gold veins are igniting, the neighbours peeking at both edges.

Make it yours

  • Your plans are PLANS in app.js: names, monthly and yearly prices, features and a seed that 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.

The one idea worth keeping

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.