This is the whole build of the liquid glass pricing section, in the order it was written: the plans of a made-up UI kit, Lumina UI. Three cards of thick clear glass stand over a slow aurora; the glass bends the light behind it, the chosen plan carries a gold light inside its rim, and choosing another plan sends that light across to it. The billing switch is a clear glass pill gliding in a groove, and the prices roll like an odometer. 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, no build step and no image: one HTML page, one style sheet and four ES modules. The aurora and the glass are two WebGL 2 shaders; every word on the page is real DOM text above them, so it stays sharp and readable.
1 · The section, in HTML and CSS
Start with the markup. Each plan is a card in a radio group; the price, the billing line and the features are plain text. The featured plan is a little larger.
<div class="card is-featured" data-plan="pro" role="radio" aria-checked="true" tabindex="0" aria-labelledby="n-pro p-pro" aria-describedby="f-pro">
<div class="name" id="n-pro">Pro</div>
<div class="price" data-m="25" data-y="250">
<span class="sr-only" id="p-pro">$25 per month</span>
<span class="cur" aria-hidden="true">$</span><span class="amt" aria-hidden="true">25</span><span class="per" aria-hidden="true"><span class="per-m">/mo</span><span class="per-y">/yr</span></span>
</div>
<div class="more">
<div class="more-in">
<p class="bill"><span class="bill-m">Billed monthly</span><span class="bill-y">Two months free</span></p>
<hr>
<ul class="feats" id="f-pro">
<li>All 640 smart components</li>
<li>Premium Figma kit</li>
<li>Easy theming with tokens</li>
<li>Liquid light motion</li>
</ul>
</div>
</div>
</div>
The billing control is a real switch between two labels, and both labels are buttons too.
<div class="billing" role="group" aria-label="Billing period">
<button type="button" class="bl bl-m" data-billing="monthly" aria-pressed="true">Monthly</button>
<button type="button" class="switch" role="switch" aria-checked="false" aria-label="Bill yearly (two months free)">
<span class="track" aria-hidden="true"></span>
</button>
<button type="button" class="bl bl-y" data-billing="yearly" aria-pressed="false">Yearly</button>
</div>
.cards { display: flex; align-items: center; justify-content: center; gap: var(--gap); margin-top: 30px; }
.card {
position: relative; width: var(--card-w); min-height: var(--card-h); padding: 40px 30px 30px; border-radius: var(--r);
display: flex; flex-direction: column; align-items: center; cursor: pointer; outline: none; -webkit-tap-highlight-color: transparent;
text-shadow: 0 1px 14px rgba(12, 8, 40, .35); user-select: none; -webkit-user-select: none;
}
.card.is-featured { width: var(--pro-w); min-height: var(--pro-h); padding-top: 44px; }
The price has a raised dollar sign and a /mo that trades places with /yr. Both sit in one
grid cell, so they can cross-fade without moving anything.
.price { text-shadow: none; display: flex; justify-content: center; align-items: flex-start; margin-top: 16px; font-size: var(--price); line-height: 1; font-weight: 640; letter-spacing: -.025em; font-variant-numeric: tabular-nums; white-space: nowrap; }
.card.is-featured .price { --price: 66px; font-size: var(--price); }
.cur { font-size: .44em; margin: .12em .06em 0 0; font-weight: 600; }
.amt { display: inline-flex; }
.per { display: inline-grid; align-self: flex-end; margin: 0 0 .06em .08em; font-size: .4em; font-weight: 450; letter-spacing: 0; color: var(--ink-2); }
.per > span, .bill > span { grid-area: 1 / 1; transition: opacity .45s var(--ease), transform .55s var(--ease); }
.per-y, .bill-y { opacity: 0; transform: translateY(.6em); }
[data-billing="yearly"] .per-m, [data-billing="yearly"] .bill-m { opacity: 0; transform: translateY(-.6em); }
[data-billing="yearly"] .per-y, [data-billing="yearly"] .bill-y { opacity: 1; transform: none; }
On a phone the cards stack, and only the chosen plan shows its features: the others fold to a name and a price.
A grid-template-rows transition from 0fr to 1fr opens and closes them.
.more { grid-template-rows: 0fr; transition: grid-template-rows .6s var(--ease); }
.card[aria-checked="true"] .more { grid-template-rows: 1fr; }
.more-in { opacity: 0; visibility: hidden; transition: opacity .35s var(--ease), visibility 0s linear .6s; }
.card[aria-checked="true"] .more-in { opacity: 1; visibility: visible; transition: opacity .35s var(--ease) .12s, visibility 0s; }
Until the WebGL glass arrives in stage 4, the cards are frosted in CSS, and the chosen one gets a warm edge. The finished page keeps this as its fallback: a browser without WebGL 2 sees exactly this.
html.no-gl .card {
background: linear-gradient(180deg, rgba(255, 255, 255, .13), rgba(255, 255, 255, .05));
border: 1px solid rgba(255, 255, 255, .34); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .5), 0 24px 50px rgba(8, 4, 30, .45);
-webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3);
transition: translate .5s var(--ease), box-shadow .5s var(--ease);
}
html.no-gl .card:hover { translate: 0 -6px; }
html.no-gl .card[aria-checked="true"] { translate: 0 -12px; border-color: rgba(255, 210, 150, .85); box-shadow: inset 0 0 26px rgba(255, 170, 90, .35), 0 0 0 1px rgba(255, 196, 106, .4), 0 0 46px rgba(255, 160, 80, .42), 0 24px 50px rgba(8, 4, 30, .45); }
// the page has one look (dark aurora); it never follows the OS. No WebGL yet: the CSS frosted glass draws the cards.
document.documentElement.classList.add('js', 'no-gl');

2 · A radio group and a billing switch
Now the module. Choosing a plan sets aria-checked and keeps only that card in the tab order, and the
call to action follows it.
function renderPlan() {
for (const c of cards) {
const on = c.plan === state.plan;
c.el.setAttribute('aria-checked', on ? 'true' : 'false');
c.el.tabIndex = on ? 0 : -1;
}
$('.cta-plan').textContent = NAMES[state.plan];
$('.cta-price').textContent = fmt(state.plan, state.billing);
cta.setAttribute('aria-label', `Continue with ${NAMES[state.plan]}, ${srPrice(state.plan, state.billing)}`);
}
A click chooses, and so do the arrow keys (they wrap), Home, End, Space and Enter, as the WAI-ARIA radio group pattern asks.
$('.cards').addEventListener('keydown', (e) => {
const c = cards.find((x) => x.el === e.target); if (!c) return;
let i = c.i;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') i = (i + 1) % 3;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') i = (i + 2) % 3;
else if (e.key === 'Home') i = 0;
else if (e.key === 'End') i = 2;
else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setPlan(c.plan); return; }
else return;
e.preventDefault();
setPlan(PLANS[i]);
cards[i].el.focus();
});
The switch flips the billing; the labels set it directly, and the arrow keys work on the switch. Yearly is ten times the monthly price: two months free.
function setBilling(billing) {
if (billing !== 'monthly' && billing !== 'yearly') return;
state.billing = billing; store.set(KEY.billing, billing);
root.dataset.billing = billing;
const y = billing === 'yearly';
sw.setAttribute('aria-checked', y ? 'true' : 'false');
$('.bl-m').setAttribute('aria-pressed', y ? 'false' : 'true');
$('.bl-y').setAttribute('aria-pressed', y ? 'true' : 'false');
for (const c of cards) { $('.amt', c.price).textContent = priceOf(c.plan, billing); c.sr.textContent = srPrice(c.plan, billing); }
renderPlan();
}
sw.addEventListener('click', () => setBilling(state.billing === 'yearly' ? 'monthly' : 'yearly'));
sw.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') { e.preventDefault(); setBilling('yearly'); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); setBilling('monthly'); }
});
for (const b of $$('.bl')) b.addEventListener('click', () => setBilling(b.dataset.billing));
Both choices are saved. Every storage access is wrapped, so a blocked storage (a private window) just forgets.
const store = {
get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* private mode */ } },
};

3 · The prices roll
A price that jumps from 25 to 250 is a text change; a price that rolls is a moment. Each price becomes three digit columns, and each column is a reel of the digits 0 to 9 three times over, so it can roll forward or back by a full turn and land on the right digit.
// odometer: each price becomes three digit columns (the third only shows for yearly), each a reel 0–9 × 3
const DIG = 3;
for (const c of cards) {
const amt = $('.amt', c.price);
amt.textContent = '';
c.cols = [];
for (let k = 0; k < DIG; k++) {
const col = document.createElement('span'); col.className = 'dg';
const reel = document.createElement('span'); reel.className = 'reel';
for (let n = 0; n < 30; n++) { const s = document.createElement('span'); s.textContent = n % 10; reel.appendChild(s); }
col.appendChild(reel); amt.appendChild(col);
reel.addEventListener('transitionend', (e) => {
if (e.propertyName !== 'transform') return;
reel.classList.remove('is-rolling'); reel.style.setProperty('--i', reel._d + 10);
});
c.cols.push({ col, reel });
}
}
Switching the billing rolls every reel, staggered by card and by digit, and the third column grows in for the yearly hundreds. When a reel lands, it jumps back to the middle copy of its digits without a transition, ready for the next roll.
function setDigits(c, billing, animate) {
const s = String(priceOf(c.plan, billing)), dir = billing === 'yearly' ? 1 : -1;
c.cols.forEach(({ col, reel }, k) => {
const on = k < s.length, d = on ? +s[k] : 0;
col.classList.toggle('is-off', !on);
if (!animate) { reel.classList.remove('is-rolling'); reel._d = d; reel.style.setProperty('--i', d + 10); return; }
reel._d = d;
reel.style.transitionDelay = `${c.i * 70 + k * 85}ms`;
reel.classList.add('is-rolling');
reel.style.setProperty('--i', d + 10 + dir * 10);
});
}
.dg { display: inline-block; height: 1.08em; overflow: hidden; transition: max-width .7s var(--ease), opacity .5s var(--ease); max-width: .7em; }
.dg.is-off { max-width: 0; opacity: 0; }
.reel { display: flex; flex-direction: column; transform: translateY(calc(var(--i, 0) * -1.08em)); }
.reel.is-rolling { transition: transform var(--roll) cubic-bezier(.22, .9, .25, 1.04); }
.reel > span { display: block; height: 1.08em; line-height: 1.08em; text-align: center; }
With reduced motion the prices simply change. The page follows the OS setting live.
rmQuery.addEventListener('change', () => { state.reduced = rmQuery.matches || params.has('still'); root.classList.toggle('rm', state.reduced); });

4 · The aurora and the glass, in WebGL
Two canvases arrive. Behind the page, one shader paints the aurora: domain-warped curtains of teal, coral and violet on deep indigo that drift and breathe. It is all soft light, so it renders at a third of the CSS resolution.
// A · the teal curtain, from the top right, bending down to the left; its lower edge is the brightest
float uc = 0.74 - 0.50 * p.y - 0.06 * p.y * p.y + 0.05 * sin(p.y * 4.2 + t * 0.06);
float wA = 0.095 + soft * 0.045;
float dA = p.x - uc;
float A = exp(-dA * dA / (wA * wA)) * smoothstep(1.02, 0.18, p.y);
float edge = exp(-pow((dA + wA * 0.55) / (wA * 0.55), 2.0)) * smoothstep(0.95, 0.3, p.y);
col += vec3(0.15, 0.62, 0.51) * A * ray * 1.12 * breathe;
col += vec3(0.34, 0.82, 0.62) * edge * ray * 0.40 * breathe;
in vec2 aPos; void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;
const FS = `#version 300 es
precision highp float;
out vec4 o;
uniform vec2 uRes;
uniform float uTime;
${FIELD}
void main() {
vec2 s = vec2(gl_FragCoord.x, uRes.y - gl_FragCoord.y) / uRes;
vec3 c = aurora(s, uTime, 0.0);
// a hint of dither against banding in the deep indigo
c += (hash12(gl_FragCoord.xy + fract(uTime) * 61.0) - 0.5) / 255.0;
o = vec4(c, 1.0);
}
Under the text, a second canvas draws the glass, one scissored draw per object. The trick that makes it look like glass: it asks the same aurora function what is behind each pixel, at a bent position. Near the rim the glass pulls the outside in, and red, green and blue bend by slightly different amounts.
float e = 1.0 - clamp(depth / W, 0.0, 1.0); // 1 at the rim, 0 past the bevel
vec2 nO = nRR(q, b, R);
float bend = e * e * e;
// a ripple running through the glass (where the click landed)
float rd = length(p - uRip.xy);
float wv = uRip.w * exp(-pow((rd - uRip.z) / 16.0, 2.0)) * sin((rd - uRip.z) / 4.8);
vec2 sp = p + nO * bend * W * 1.9 + (c - p) * 0.045 + normalize(p - uRip.xy + 1e-3) * wv * 9.0;
vec3 bgc;
if (e > 0.002) {
float k = bend * 6.0;
bgc = vec3(bgAt(sp + nO * k, 0.85).r, bgAt(sp, 0.85).g, bgAt(sp - nO * k, 0.85).b);
} else bgc = bgAt(sp, 0.85);
A crisp rim catches a lamp at the top left, with a second catch on the far corner.
// the rim: a crisp line of light, strongest facing the lamp, a second catch on the far corner
float rim = exp(-pow((depth - 1.25) / 1.15, 2.0));
float facing = dot(nO, normalize(vec2(-0.55, -0.83)));
float rimK = (0.26 + 0.74 * smoothstep(-0.15, 0.95, facing) + 0.40 * smoothstep(0.45, 1.0, -facing)) * (1.0 + 0.55 * hov);
ic += vec3(0.95, 0.96, 1.0) * rim * rimK;
The chosen plan carries the light: gold, amber and rose streaks running inside a rounder version of the rim, with a white-hot core. Every frequency along the perimeter is a whole number of turns, so the light has no seam.
if (lit) {
float s = streaks(-sdRR(q, b, pill ? b.y : min(min(b.x, b.y) * 0.5, 64.0)), a, uTime, sc);
ic *= 1.0 - g * min(s, 1.0) * 0.35; // the light outshines what is behind it
ic = mix(ic, ic * vec3(0.92, 0.86, 0.8), g * 0.5);
ic += g * (gc * s * 1.05 + vec3(1.0, 0.94, 0.82) * pow(min(s, 1.4), 3.0) * 0.35 + gc * exp(-depth / (40.0 * sc)) * 0.13);
ic += g * gc * rim * 0.35;
}
The JavaScript measures where the glass goes and hands it one scene a frame. The canvas covers only where glass can draw, never the whole page.
// the canvas covers only where glass can draw: the switch, the cards with their bloom and floor, the CTA
const boxes = [...cards.map((c) => c.base), geo.track, geo.cta];
const x0 = Math.max(0, Math.floor(Math.min(...boxes.map((b) => b.cx - b.hx)) - 96));
const x1 = Math.min(sr.width, Math.ceil(Math.max(...boxes.map((b) => b.cx + b.hx)) + 96));
const y0 = Math.max(0, Math.floor(geo.track.cy - geo.track.hy - 24));
const yEnd = Math.max(geo.cta.cy + geo.cta.hy + 40, geo.row ? geo.floorY + 150 : 0);
const w = x1 - x0, h = Math.ceil((Math.min(sr.height, yEnd) - y0 + 1) / 96) * 96; // bucketed: a folding phone card does not reallocate
// at most 2× and at most ~8 M device px
const dpr = Math.round(Math.min(devicePixelRatio || 1, 2, Math.sqrt(8e6 / (w * h))) * 100) / 100;
const key = [x0, y0, w, h, dpr].join();
if (glassCv._key !== key) {
glassCv._key = key;
Object.assign(glassCv.style, { left: x0 + 'px', top: y0 + 'px', width: w + 'px', height: h + 'px' });
glass.resize(w, h, dpr, x0, y0);
}
function tick(now) {
if (!running) return;
let busy = !state.reduced && !!glass; // the aurora drifts forever unless motion is reduced
for (const c of cards) {
const sel = c.plan === state.plan;
c.lift = sel ? (geo.phone ? -3 : -12) : 0; c.glow = sel ? 1 : 0;
const px = Math.round(c.lift * 2) / 2;
if (px !== c.liftPx) { c.liftPx = px; c.el.style.translate = px ? `0 ${px}px` : ''; }
}
draw(now);
if (busy) requestAnimationFrame(tick); else running = false;
}

5 · The glass moves
Now the motion. The chosen card lifts on a slightly bouncy spring, the light fades in and out smoothly, and hovering a card lifts it a little while a soft light follows the pointer across its glass.
// a slightly bouncy spring for the lift, smooth approaches for the light
c.liftV += (220 * (lt - c.lift) - 2 * Math.sqrt(220) * 0.62 * c.liftV) * dt; c.lift += c.liftV * dt;
c.hover += (c.hoverT - c.hover) * (1 - Math.exp(-dt * 9));
c.glow += ((sel ? 1 : 0) - c.glow) * (1 - Math.exp(-dt * (sel ? 3.2 : 4.5)));
if (sel && c.ignite < 1) c.ignite = Math.min(1, c.ignite + dt / 0.95);
When you choose another plan, the light leaves the old card and enters the new one from the side it came from, and a ripple runs through the glass from where you clicked.
function setPlan(plan, { animate = true, from = null } = {}) {
if (!PLANS.includes(plan)) return;
const prev = byPlan[state.plan], next = byPlan[plan];
if (plan !== state.plan) {
state.plan = plan; store.set(KEY.plan, plan);
if (animate && !state.reduced) {
next.ignite = 0;
// the light enters from the side of the card it left
next.entry = [prev.base.cx, prev.base.cy + prev.lift * geo.z];
} else next.ignite = 1;
}
if (from && animate && !state.reduced && glass) next.rip = { x: from[0], y: from[1], t0: performance.now() };
renderPlan();
kick();
}
a = perim(q, b); aE = perim(uPtr.zw - c, b);
float da = abs(fract(a - aE + 0.5) - 0.5);
g = glow * (1.0 - smoothstep(ign * 0.62 - 0.12, ign * 0.62, da));
// a ripple running through the glass (where the click landed)
float rd = length(p - uRip.xy);
float wv = uRip.w * exp(-pow((rd - uRip.z) / 16.0, 2.0)) * sin((rd - uRip.z) / 4.8);
vec2 sp = p + nO * bend * W * 1.9 + (c - p) * 0.045 + normalize(p - uRip.xy + 1e-3) * wv * 9.0;
The switch's pill glides like water. Its two edges ride springs: the leading edge sets off first and the trailing edge follows, so the pill lengthens while it flows and shortens as it settles. Both springs are almost critically damped, so it settles softly, and it is clamped inside the groove: it never leaves the track.
step(dt) {
if (!this.moving) return;
const sub = Math.max(1, Math.ceil(dt / (1 / 240))), h = dt / sub;
const kF = 360, cF = 2 * Math.sqrt(kF) * 0.86, kB = 130, cB = 2 * Math.sqrt(kB) * 0.97;
for (let i = 0; i < sub; i++) {
const lead = this.dir > 0 ? 'R' : 'L', trail = this.dir > 0 ? 'L' : 'R';
const t = { L: this.tL, R: this.tR };
this['v' + lead] += (kF * (t[lead] - this[lead]) - cF * this['v' + lead]) * h;
this['v' + trail] += (kB * (t[trail] - this[trail]) - cB * this['v' + trail]) * h;
this.L += this.vL * h; this.R += this.vR * h;
if (this.L < this.inL) { this.L = this.inL; this.vL = Math.max(0, this.vL); }
if (this.R > this.inR) { this.R = this.inR; this.vR = Math.min(0, this.vR); }
const minW = this.restW * 0.92; // it may stretch, never pinch
if (this.R - this.L < minW) { const m = (this.L + this.R) / 2; this.L = m - minW / 2; this.R = m + minW / 2; }
}
const rest = Math.abs(this.tL - this.L) + Math.abs(this.tR - this.R) < 0.04 && Math.abs(this.vL) + Math.abs(this.vR) < 0.3;
if (rest) { this.L = this.tL; this.R = this.tR; this.vL = this.vR = 0; this.moving = false; }
},

6 · The phone, and the finish
On a phone, choosing a plan folds one card and opens another, and the glass has to follow every frame. During a fold only the boxes move, so the page re-reads just those, without style queries or canvas checks.
// during a phone fold/unfold only the boxes move: re-read just those (no style queries, no canvas checks)
function measureBoxes() {
const sr = stage.getBoundingClientRect();
const rel = (r, lift = 0) => ({ cx: r.left - sr.left + r.width / 2, cy: r.top - sr.top + r.height / 2 - lift, hx: r.width / 2, hy: r.height / 2 });
for (const c of cards) c.base = rel(c.el.getBoundingClientRect(), (c.liftPx || 0) * geo.z);
geo.cta = rel(cta.getBoundingClientRect());
geo.floorY = Math.max(...cards.map((c) => c.base.cy + c.base.hy)) + 12;
}
for (const c of cards) c.el.addEventListener('transitionrun', () => { measureUntil = Math.max(measureUntil, performance.now() + 800); kick(); });
Last, the saved keys are versioned (dv53a.*), and anything older is wiped before it can confuse the page.
// the page has one look (dark aurora); it never follows the OS. Old saved state from other builds is wiped.
document.documentElement.classList.add('js');
try { for (const k of Object.keys(localStorage)) if (k.startsWith('dv53') && !k.startsWith('dv53a.')) localStorage.removeItem(k); } catch (e) {}
Measured on a 120 Hz screen, a frame of the aurora and every glass draw costs the GPU about 1 to 2 ms at 1440 × 900 and well under 1 ms on a phone.


Make it yours
- Your plans are the three cards in the HTML: the name,
data-manddata-yon the price (monthly and yearly), and the features. - Your light is
goldAt()inglass.js; your sky isaurora()infield.js. Both canvases read the same function, so the glass always refracts what the page shows. - Accessibility is already there: a real radio group with roving tabindex, a real
role=switch, a visible focus ring, 44 px targets,prefers-reduced-motionhonoured, and a CSS frosted fallback without WebGL 2.
The code is on GitHub at UlviShabanbayli/domvybe-liquid-pricing, under the MIT licence: use it in your own projects, commercial ones included. Lumina UI is made up; everything on the page was made in code for this project.
Draw the background and the glass from the same function. The glass then asks what is behind each pixel and bends the answer, so it always refracts exactly the light the page shows around it, at any size and on any frame.