Library / Tutorial

Liquid glass pricing,
step by step.

A pricing section of thick liquid glass over a drifting aurora. The chosen plan carries a gold light that pours across to the next plan you pick, the billing switch is a clear glass pill that glides like water, and the prices roll like an odometer. Six stages, each a live page you can run.

6Stages
2Shaders
0Libraries

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.

index.html — the Pro card
<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.

index.html — MONTHLY [ switch ] YEARLY
<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>
pricing.css — three cards in a row
.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.

pricing.css — the price
.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.

pricing.css — the phone stack folds
.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.

pricing.css — the CSS frosted glass
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); }
index.html — the inline script in the head
// 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');
Stage 1 · HTML and CSSOpen ↗
Lumina UI pricing in CSS frosted glass: Liquid Glass Pricing over a dark indigo backdrop, a MONTHLY / YEARLY switch, Essentials $10/mo, Pro $25/mo lifted with a warm edge, Ultimate $50/mo, and a Continue with Pro button.

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.

pricing.js — show the chosen plan
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.

pricing.js — keys
$('.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.

pricing.js — set the billing
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();
}
pricing.js — switch, labels, arrows
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.

pricing.js — storage that never throws
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 */ } },
};
Stage 2 · radio group and switchOpen ↗
The CSS frosted cards on yearly billing with Ultimate chosen: Essentials $100/yr, Pro $250/yr, Ultimate $500/yr with a warm edge, and Continue with Ultimate, $500/yr.

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.

pricing.js — build the reels
// 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.

pricing.js — roll to a price
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);
  });
}
pricing.css — a column and its reel
.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.

pricing.js — reduced motion
rmQuery.addEventListener('change', () => { state.reduced = rmQuery.matches || params.has('still'); root.classList.toggle('rm', state.reduced); });
Stage 3 · the prices rollOpen ↗
The prices caught halfway through rolling to yearly: each digit column between two numbers, and the third column growing in.

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.

field.js — the teal curtain
// 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;
aurora.js — the backdrop
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.

glass.js — the bevel bends the aurora
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.

glass.js — the rim
// 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.

glass.js — the light it carries
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.

pricing.js — the canvas covers only the glass
// 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);
    }
pricing.js — at this stage the chosen card is simply lifted and lit
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;
}
Stage 4 · the aurora and the glassOpen ↗
The WebGL glass: three thick clear cards over a teal and coral aurora, the Pro card lifted with gold, amber and rose light inside its rim, its reflection on a glossy floor, and the glass pill in the switch.

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.

pricing.js — springs
// 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.

pricing.js — where the light comes from
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();
}
glass.js — it ignites around the rim
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));
glass.js — the ripple
// 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.

pricing.js — the pill glides
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; }
  },
Stage 5 · the glass movesOpen ↗
Ultimate just chosen: the gold light igniting around its rim from the side it came from, the card lifting, and the Pro card cooled to clear glass.

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.

pricing.js — a fold re-reads only the boxes
// 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;
}
pricing.js — a fold keeps the loop awake
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.

index.html — the final head script
// 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.

Stage 6 · the finished sectionOpen ↗
The finished section on yearly billing: Essentials $100/yr, Pro $250/yr, and Ultimate $500/yr lit with gold light, over the aurora.
Stage 6 · on a phoneOpen ↗
The section on a phone: PLANS, the switch on yearly, the cards stacked, and Essentials open at $100 a year with the gold light inside its rim.

Make it yours

  • Your plans are the three cards in the HTML: the name, data-m and data-y on the price (monthly and yearly), and the features.
  • Your light is goldAt() in glass.js; your sky is aurora() in field.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-motion honoured, 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.

The one idea worth keeping

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.