This is the whole build of the gem rating, in the order it was written: the review card of a made-up hotel, The Alder House. "How was your night?" and five stones. Lit stones are golden topaz that glows from within; the others are smoky crystal that still throws a little cold fire. 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 stones are one transparent WebGL canvas behind the card; every word and every control stays real DOM, and the rating is five native radio buttons.
1 · The card, and stones in SVG
The rating is a fieldset of five radios with a legend. Each radio fills a label laid exactly over its stone, so the stone is the hit area, and arrows, Tab and screen readers work without extra code.
<fieldset class="rating" id="rating" aria-describedby="score msg">
<legend class="sr-only">Rate your stay, from one to five stones</legend>
<div class="gems" id="gems">
<label class="gem" data-i="0"><input type="radio" name="rating" value="1" aria-label="1 of 5 — Poor"><span class="ring" aria-hidden="true"></span></label>
<label class="gem" data-i="1"><input type="radio" name="rating" value="2" aria-label="2 of 5 — Fair"><span class="ring" aria-hidden="true"></span></label>
<label class="gem" data-i="2"><input type="radio" name="rating" value="3" aria-label="3 of 5 — Good"><span class="ring" aria-hidden="true"></span></label>
<label class="gem" data-i="3"><input type="radio" name="rating" value="4" aria-label="4 of 5 — Exceptional" checked><span class="ring" aria-hidden="true"></span></label>
<label class="gem" data-i="4"><input type="radio" name="rating" value="5" aria-label="5 of 5 — Unforgettable"><span class="ring" aria-hidden="true"></span></label>
</div>
</fieldset>
.gem { position: relative; display: block; width: var(--pitch); height: calc(var(--pitch) * .86); cursor: pointer; -webkit-tap-highlight-color: transparent; }
.gem input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: inherit; width: 100%; height: 100%; }
Keyboard focus draws a ring of light round the focused stone.
.gem:has(input:focus-visible) .ring { border-color: rgba(252, 216, 133, .9); transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 22px rgba(252, 216, 133, .18), inset 0 0 18px rgba(252, 216, 133, .08); }
Two small additions to the radios. Clicking the chosen stone again clears the rating (a radio cannot do that on its own). Hovering a stone previews that rating; leaving the row restores the chosen one.
x.addEventListener('click', (e) => {
if (st.posted) return;
// clicking the chosen stone again clears the rating
if (st.rating === i + 1 && st.clickArmed === i) { x.checked = false; setRating(0); }
});
x.addEventListener('pointerdown', () => { st.clickArmed = st.rating === i + 1 ? i : -1; });
labels.forEach((l, i) => {
l.addEventListener('pointerenter', (e) => { if (e.pointerType !== 'mouse' || st.posted) return; st.preview = i + 1; renderScore(); targets(); wake(); });
});
$('gems').addEventListener('pointerleave', (e) => { if (!st.preview) return; st.preview = 0; renderScore(); targets(); wake(); });
Every stone has a light value that eases to its target; a committed change gives each newly lit stone a small delay, so they light up one after the other.
function targets() {
const r = st.preview || st.rating;
st.gems.forEach((g, i) => { g.litT = i < r ? 1 : 0; g.dimT = st.posted ? 1 : 0; });
}
function ignite(from, to, stagger = 95) {
for (let i = from; i < to; i++) st.gems[i].delay = (i - from) * stagger / 1000;
targets(); wake();
}
The first stones are faceted SVG stars: ten triangles, a light and a dark face per arm, and a small table. They stay in the finished page as the fallback without WebGL.
function star(cx, cy, R, rot, lit) {
const ri = R * .44, pts = [];
for (let k = 0; k < 10; k++) { const a = (k * 36 + rot) * D, r = k % 2 ? ri : R; pts.push([cx + r * Math.sin(a), cy - r * Math.cos(a)]); }
let s = '';
for (let k = 0; k < 10; k++) {
const p = pts[k], q = pts[(k + 1) % 10];
const fill = lit ? (k % 2 ? 'url(#fbL2)' : 'url(#fbL1)') : (k % 2 ? 'url(#fbU2)' : 'url(#fbU1)');
s += `<path d="M${cx.toFixed(1)} ${cy.toFixed(1)}L${p[0].toFixed(1)} ${p[1].toFixed(1)}L${q[0].toFixed(1)} ${q[1].toFixed(1)}Z" fill="${fill}" stroke="${lit ? 'rgba(255,236,190,.35)' : 'rgba(200,205,215,.12)'}" stroke-width=".6"/>`;
}
const tb = []; for (let k = 0; k < 10; k++) { const a = (k * 36 + rot) * D, r = (k % 2 ? ri : R) * .28; tb.push(`${(cx + r * Math.sin(a)).toFixed(1)},${(cy - r * Math.cos(a)).toFixed(1)}`); }
s += `<polygon points="${tb.join(' ')}" fill="${lit ? 'rgba(255,248,220,.55)' : 'rgba(170,175,185,.18)'}"/>`;
return s;
}
The review field grows with its text and shows a counter near the limit. Posting checks the rating and the length, shows a busy button, then locks the review read-only with an Edit button.
function fitText() { text.style.height = 'auto'; text.style.height = (text.value ? Math.min(140, Math.max(52, text.scrollHeight + 2)) : 52) + 'px'; }
$('review').addEventListener('submit', (e) => {
e.preventDefault();
if (st.posted || st.busy) return;
if (!st.rating) {
setMsg('Choose a rating first — tap a stone.', 'rating');
inputs.forEach((x) => x.setAttribute('aria-invalid', 'true'));
shake(); inputs[0].focus({ preventScroll: true });
return;
}
if (text.value.length > MAXLEN) { setMsg(`Keep it under ${MAXLEN} characters — you are ${text.value.length - MAXLEN} over.`, 'text'); shake(); text.focus(); return; }
setMsg(''); st.busy = true;
const b = $('post'); b.classList.add('busy'); b.setAttribute('aria-disabled', 'true'); $('postl').textContent = 'Posting…';
announce('Posting your review…');
setTimeout(() => {
st.busy = false; b.classList.remove('busy'); b.classList.add('done'); $('postl').textContent = 'Posted';
// success: the review is read-only
lock(true);
announce(`Thank you. Your ${st.rating}-stone review of The Alder House is live.`);
}, reduced ? 250 : 900);
});
function lock(on) {
st.posted = on; st.preview = 0;
$('rating').disabled = on; text.disabled = on;
$('after').hidden = !on;
const b = $('post');
if (on) { b.setAttribute('aria-disabled', 'true'); b.classList.add('done'); $('postl').textContent = 'Posted'; }
else { b.removeAttribute('aria-disabled'); b.classList.remove('done'); $('postl').textContent = 'Update review'; }
renderScore(); targets(); wake();
}
Nothing runs when nothing changes: the loop asks for the next frame only while a stone is still easing.
function loop(now) {
const dt = Math.min((now - st.last) / 1000, 1 / 30); st.last = now;
let busy = false, changed = false;
st.gems.forEach((g) => {
// light: a quick ease to the target (a little faster going out), after its place in the sequence
if (g.delay > 0) { g.delay -= dt; busy = true; }
else if (g.lit !== g.litT) {
const sp = reduced ? 1 / .15 : g.litT > g.lit ? 1 / .42 : 1 / .32;
g.lit = g.litT > g.lit ? Math.min(g.litT, g.lit + dt * sp) : Math.max(g.litT, g.lit - dt * sp);
busy = changed = true;
}
if (g.dim !== g.dimT) { g.dim = g.dimT > g.dim ? Math.min(g.dimT, g.dim + dt / .35) : Math.max(g.dimT, g.dim - dt / .35); busy = changed = true; }
});
if (changed) st.fb.draw(st.gems);
if (busy) requestAnimationFrame(loop); else st.running = false;
}

2 · The gems
Each stone is traced, not modelled: a ray from the camera enters a star-cut gem, refracts, bounces inside and leaves. The cut is a five-point star with a crown and a pavilion, and the whole shape is ten copies of one half arm. So the tracer folds every point into that one half sector, where the stone is just eight flat facets: a ray tests eight planes and two walls, and mirrors across a wall when it crosses one. No meshes, no marching.
mat3 foldAt(vec3 p){
float a = atan(p.x, p.y);
float k = floor(a/ALPHA + .5), b = k*ALPHA, c = cos(b), s = sin(b);
mat3 R = mat3(c, s, 0., -s, c, 0., 0., 0., 1.);
vec3 q = R*p;
return q.x < 0. ? S0*R : R;
}
Diamond-like numbers make the fire: a refractive index of 2.42, so most light is trapped by total internal reflection, and a small spread of the index per colour. Lit and unlit stones only differ in what the glass absorbs.
const float IOR = 2.42, DISP = .05, F0 = .172;
const vec3 AL = vec3(.0028, .0108, .046); // lit: blue and green absorbed → golden topaz
const vec3 AU = vec3(.040, .039, .036); // unlit: neutral smoke
Inside, each segment of the path is absorbed (Beer–Lambert) and gathers a little warm light, so a lit stone glows from within.
// inner glow: the lit stone holds a warm light that every path gathers, the smoky one a faint cold one
float gw = (1. - exp(-seg*.045)) * thr;
cL += vec3(.095, .062, .019) * gw * tL;
cU += vec3(.010, .011, .013) * gw * tU;
path += seg;
tL *= exp(-AL*seg); tU *= exp(-AU*seg);
Where the ray meets a facet, part of it leaves. Red, green and blue leave at slightly different angles, which is the rainbow fire; the rest reflects and carries on, up to nine events.
vec3 vo = refract(v, -n, IOR);
if (dot(vo, vo) > .01){
float ct = max(dot(vo, n), 0.);
float Fi = F0 + (1. - F0)*pow(1. - ct, 5.);
mat3 A = O2W*transpose(M);
vec3 vR = refract(v, -n, IOR - DISP), vB = refract(v, -n, IOR + DISP);
vec3 ex = vec3(env(A*(dot(vR, vR) > .01 ? vR : vo)).r, env(A*vo).g, env(A*(dot(vB, vB) > .01 ? vB : vo)).b);
float w = thr*(1. - Fi);
cL += ex*tL*w; cU += ex*tU*w;
thr *= Fi;
}
v = reflect(v, n);
The tracer writes the lit and the unlit stone at once, into two targets. Lighting a stone never traces again: the frame mixes the two, and the light spreads from the heart of the stone outwards.
// the light spreads from the heart of the stone outwards
float r = length(px - uGem[gi].xy)/uGem[gi].z, e = uGem[gi].w;
float m = 1. - smoothstep(e*1.35 - .35, e*1.35, r);
At rest the image is refined: many jittered full-resolution samples, a few horizontal bands per frame within a time budget measured on this machine, then the tracer stops.
function traceBands(k) {
gl.bindFramebuffer(gl.FRAMEBUFFER, R.acc.f); gl.viewport(0, 0, R.box[2], R.box[3]);
gl.enable(gl.SCISSOR_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
traceUniforms(TR, R.restRot, [R.box[0], R.box[1]], R.scale, R.center, 1, 1);
let n = 0;
for (let j = 0; j < k; j++) {
const b = R.nextBand, [y, h] = R.bands[b];
if (R.cnt[b] >= R.target) break;
gl.scissor(0, y, R.box[2], h);
if (R.cnt[b] === 0) { gl.disable(gl.BLEND); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.enable(gl.BLEND); }
gl.uniform1f(TR.u.uSeed, R.cnt[b] + b * .37);
gl.drawArrays(gl.TRIANGLES, 0, 3);
R.cnt[b]++; R.stats.bands++; n++;
R.nextBand = (b + 1) % R.bands.length;
}
gl.disable(gl.BLEND); gl.disable(gl.SCISSOR_TEST);
return n;
}
R.frame = function (st, dt) {
if (gl.isContextLost()) return false;
let drew = false;
const moving = []; for (let i = 0; i < 5; i++) if (st.gems[i].moving) moving.push(i);
const rot = new Float32Array(45); for (let i = 0; i < 5; i++) rot.set(st.gems[i].rot, i * 9);
let spent = 0;
if (R.liveFresh) { traceLive(rot, [0, 1, 2, 3, 4], 0, ++seed % 997); R.liveFresh = false; drew = true; spent += R.liveBudget; R.trail = new Set(moving); }
else if (moving.length) {
// the first frame of a turn replaces, later ones blend over (a short trail)
const fresh = moving.filter((i) => !R.trail.has(i)), cont = moving.filter((i) => R.trail.has(i));
if (fresh.length) traceLive(rot, fresh, 0, ++seed % 997);
if (cont.length) traceLive(rot, cont, st.instant ? 0 : .32, ++seed % 997);
spent += R.liveBudget * moving.length / 5; drew = true;
}
// stones that just stopped: one clean frame at rest
const stopped = [...(R.trail || [])].filter((i) => !st.gems[i].moving);
if (stopped.length) { traceLive(rot, stopped, 0, ++seed % 997); drew = true; }
R.trail = new Set(moving);
if (R.busy()) {
const per = spent > 0 ? 1 : Math.max(1, Math.floor(R.budget * 1.3 / Math.max(.05, R.costPx * R.box[2] * R.bandH)));
if (traceBands(Math.min(per, R.bands.length))) drew = true;
if (R.bands.every((_, i) => R.cnt[i] >= 1) && R.mix < 1) { R.mix = Math.min(1, R.mix + (st.instant ? 1 : dt / .25)); drew = true; }
}
if (drew || st.changed || R.dirty) { post(st); show(st); R.dirty = false; R.stats.frames++; return true; }
return false;
};
The glints are a bright pass at quarter resolution, smeared into four-point streaks; a slow per-cell twinkle keeps them breathing.
// twinkle: each little cell of glints breathes on its own clock
vec2 cell = floor(gl_FragCoord.xy/5.);
float ph = h21(cell)*6.2831, sp = 1.3 + 2.2*h21(cell + 4.);
float tw = mix(1., .25 + 1.35*pow(.5 + .5*sin(uTime*sp + ph), 3.), uTw);
float burst = 1. + uGem2[gi].y*2.6;
Now a committed change can ignite: each newly lit stone turns once about its own tip and throws a burst of glints. A turn is always one whole revolution, so the refined image is valid again the moment it stops. Choosing five flares all five.
function ignite(from, to, stagger = 95) {
if (to > from) {
for (let i = from; i < to; i++) {
const g = st.gems[i], k = i - from;
g.delay = k * stagger / 1000;
if (!reduced) { g.spinOn = true; g.spinV = 0; g.spinStart = g.delay; }
g.burstAt = g.delay;
}
if (to === 5) st.gems.forEach((g, i) => { g.burstAt = (to - from - 1) * stagger / 1000 + .08 + i * .02; g.flare = 1.4; });
}
targets(); wake();
}
// one full turn about its own tip axis, eased with a soft settle (ends exactly at a whole turn)
if (g.spinOn) {
if (g.spinStart > 0) { g.spinStart -= dt; }
else {
[g.spin, g.spinV] = spring(g.spin, g.spinV, TAU, 46, 10.5, dt);
if (Math.abs(TAU - g.spin) < 2e-3 && Math.abs(g.spinV) < .02) { g.spin = 0; g.spinV = 0; g.spinOn = false; }
}
busy = true;
Idle, the glints twinkle for a few seconds, then the loop sleeps and wakes for the next twinkle.
const more = busy || st.tw > 0;
if (more) requestAnimationFrame(loop);
else {
st.running = false;
// asleep: wake for the next idle twinkle
if (!reduced && st.fx) { clearTimeout(idleTimer); const T = st.idleT % 7.5; idleTimer = setTimeout(wake, Math.max(50, (7.5 - T) * 1000)); }
}
Without WebGL, with ?nofx or after a lost context, the SVG stones from stage 1 take over.
if (!qs.has('nofx')) { try { st.fx = createRenderer($('gl'), { onLost: fallback }); } catch (e) { console.info('gem renderer unavailable:', e.message); st.fx = null; } }
if (st.fx) { st.fx.setTilts(TILT); resize(); st.fx.calibrate(); }
else fallback(true);

3 · The finish
A first visit opens with the stones dark; then they ignite one by one up to the rating, each with its turn and its burst.
// intro: the stones ignite one by one up to the chosen rating, each with a turn and a burst of sparkle
if (reduced || st.posted) { st.gems.forEach((g, i) => { g.lit = g.litT = i < st.rating ? 1 : 0; g.dim = g.dimT = st.posted ? 1 : 0; }); }
else setTimeout(() => ignite(0, st.rating, 170), 260);
The draft (rating and text) is kept while you write, under a versioned key; older keys of the component are wiped before the first paint, and posting removes the draft.
try { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('dv80') && k !== 'dv80.draft') localStorage.removeItem(k); } } catch (e) {}
function load() { try { const v = JSON.parse(localStorage.getItem(KEY) || 'null'); return v && typeof v === 'object' ? v : null; } catch (e) { return null; } }
const draft = load();
const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ r: st.rating, t: $('text').value })); } catch (e) {} };
?disabled opens a review that is already posted: read-only, the stones dimmed, Edit review.
if (qs.has('disabled')) { st.posted = true; lock(true); $('postl').textContent = 'Posted'; }
A small test hook reports the state, so the build can be checked.
window.__gem = {
ready, st, setRating, lock,
freeze: (on) => { st.frozen = !!on; if (!on) wake(); },


Make it yours
- Your words are
NUMandWORDinapp.js, and the copy inindex.html. - Your stone is two lines in
shaders.js: the absorptionAL(golden topaz absorbs blue and green) and the glow colour. Absorb red and green for a sapphire, green and blue a little less for a citrine. - Accessibility is already there: native radios in a fieldset with a legend, a visible focus ring, a live
region for the rating and the posting, role=alert errors, 44 px targets on touch, every word in the DOM,
prefers-reduced-motion(no intro, no turns, static glints) and SVG stones without WebGL.
The Alder House is a made-up brand. Everything on the page was made in code for this project: the gems, the glints and the floor are traced by the shader, the fallback is SVG.
Trace once, light for free. The tracer writes the lit and the unlit stone together and refines them at rest; lighting, hover and bursts only mix what is already there, so five refracting gems cost a couple of milliseconds, and nothing once the loop sleeps.