Library / Tutorial

Water pricing,
step by step.

A team-size pricing slider made of water. Drag the water in a tall glass, or use the keys, and the price tag rides the meniscus; the glass, the water and the light are traced by one shader. Every word stays DOM. Three stages, each a live page you can run.

3Stages
1Canvas
0Libraries

This is the whole build of the water pricing slider, in the order it was written: the pricing page of a made-up team workspace, Tidal. The team size is the water in a tall glass. Drag it up or down, or use the keys, and the price tag rides the waterline; above fifty the glass fills to the brim and the price becomes "Custom". 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 glass is one transparent WebGL 2 canvas that covers only the vessel and its shadow; every word (the tag, the etched numbers, the prices, the buttons) stays real DOM, and the slider itself is a focusable element with role="slider" laid over the glass.

1 · The page, and a glass in CSS

The stage holds the glass and everything that rides on it. The slider is an empty element over the glass, so the keyboard, the screen reader and the pointer all talk to one real control.

index.html — the stage
<section class="stage" id="stage" aria-label="Team size">
  <div class="cv" id="cv" aria-hidden="true"><i class="cv-water" id="cv-water"></i><i class="cv-glass"></i></div>
  <div class="scale" id="scale" aria-hidden="true"><b>Seats</b><i>10</i><i>20</i><i>30</i><i>40</i><i>50</i></div>
  <div class="vessel" id="vessel" tabindex="0" role="slider" aria-label="Team size" aria-describedby="hint"
       aria-valuemin="1" aria-valuemax="51" aria-valuenow="24" aria-valuetext="24 seats, $288 per month" aria-orientation="vertical"></div>
  <div class="tag" id="tag" aria-hidden="true">
    <div class="pill"><div class="n" id="tn">24 seats</div><div class="s" id="ts">$288 / month</div></div>
    <i class="lead-line"></i><i class="dot"></i>
  </div>
  <p class="hint" id="hint">drag the water<span class="k"> · <kbd aria-label="up arrow">↑</kbd> <kbd aria-label="down arrow">↓</kbd> to fine-tune</span></p>
</section>

The first glass is CSS: frosted walls, and a water column with an elliptical surface on top.

water.css — the glass
.cv-glass { position: absolute; inset: 0; border-radius: 22px 22px 30px 30px / 14px 14px 26px 26px; border: 1.5px solid rgba(255, 255, 255, .85);
  background: linear-gradient(90deg, rgba(255, 255, 255, .5), rgba(255, 255, 255, .12) 16%, rgba(255, 255, 255, .0) 40%, rgba(255, 255, 255, .55) 44%, rgba(255, 255, 255, .05) 50%, rgba(255, 255, 255, .25) 84%, rgba(255, 255, 255, .55));
  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); box-shadow: inset 0 -26px 22px -12px rgba(255, 255, 255, .55), 0 26px 40px -18px rgba(18, 35, 46, .25); }
water.css — the water
.cv-water { position: absolute; left: 4%; right: 4%; bottom: 6%; border-radius: 0 0 22px 22px / 0 0 18px 18px;
  background: linear-gradient(90deg, #3aa3b3, #67cad6 30%, #84d8e2 46%, #5fc3d0 70%, #3497a8); }

A small camera projects a point of the glass (in world units) to CSS pixels in the stage. The layout, the tag, the etched numbers and the pointer all use it, and stage 2 hands the same numbers to the shader, so the DOM and the picture line up.

vessel.js — project a point
project(P) {
  const d = sub(P, CAM), z = dot(d, this.fw);
  return [this.cx + dot(d, this.rt) / z * this.fl, this.cy - dot(d, this.up) / z * this.fl];
}

Dragging needs the inverse: which water level is under the pointer. The projection is monotonic along the front of the glass, so a bisection finds it.

vessel.js — the level under a pointer
levelAt(sy) {
  let a = -200, b = HV + 200;
  for (let i = 0; i < 28; i++) { const m = (a + b) / 2; if (this.levelY(m) > sy) a = m; else b = m; }
  return (a + b) / 2;
}
app.js — the tiers
// ---------- pricing ----------
const perSeat = (s) => (s < 10 ? 14 : s < 25 ? 12 : 10);
const total = (s) => s * perSeat(s);
const fmt = (n) => '$' + Math.round(n).toLocaleString('en-US');
const seatsText = (s) => (s > MAXS ? '50+ seats' : `${s} seat${s === 1 ? '' : 's'}`);
const seatsFromLevel = (lev) => (lev > YMAX + (YTOP - YMAX) * 0.5 ? MAXS + 1 : Math.max(1, Math.min(MAXS, Math.round((lev - YMIN) / (YMAX - YMIN) * MAXS))));

The pointer is captured on press, so a drag keeps working when it leaves the glass. Mouse, pen and touch take the same path; only the glass has touch-action: none, so the page still scrolls everywhere else.

app.js — drag the water
els.vessel.addEventListener('pointerdown', (e) => {
  if (e.button !== 0 || locked()) return;
  e.preventDefault();
  dragging = true;
  els.vessel.setPointerCapture(e.pointerId); els.vessel.focus({ preventScroll: true });
app.js — the keys of a slider
els.vessel.addEventListener('keydown', (e) => {
  const step = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 5, PageDown: -5 }[e.key];
  let to = null;
  if (step) to = seats + step; else if (e.key === 'Home') to = 1; else if (e.key === 'End') to = MAXS + 1;
  if (to === null) return;
  e.preventDefault();
  if (locked()) return;
  setSeats(to);
});

The level never jumps to the target: a heavy spring carries it there, integrated in small steps so it stays stable at any frame rate. Stiffer while the hand is on it, softer when it settles.

app.js — the heavy spring
// the level: a heavy spring (stiffer while dragging), integrated in small steps
const k = dragging ? 120 : 85, c = dragging ? 17 : 12.5;
if (reduced) { st.lev = st.tgt; st.vel = 0; }
else {
  const h = 1 / 480; let tt = dt;
  while (tt > 1e-6) {
    const s = Math.min(h, tt); tt -= s;
    const a = k * (st.tgt - st.lev) - c * st.vel;
    st.vel += a * s; st.lev += st.vel * s;
  }
  if (Math.abs(st.tgt - st.lev) < 0.03 && Math.abs(st.vel) < 0.05) { st.lev = st.tgt; st.vel = 0; }
}
if (st.lev !== st.tgt || st.vel) moving = true;

The tag is projected onto the waterline at the side of the glass every frame, so the price literally rides the water.

app.js — the tag rides the meniscus
function placeOverlays() {
  const x = sideRight ? RO + 1 : -RO - 1, y = st.lev + 2.6;
  const p = view.project([x, y, 0]);
  const tx = sideRight ? p[0] + 3 : p[0] - tagW - 3, ty = p[1] - tagH / 2;
  const k = `${tx.toFixed(1)},${ty.toFixed(1)}`;
  if (k !== tagAt) { tagAt = k; els.tag.style.transform = `translate(${tx.toFixed(1)}px, ${ty.toFixed(1)}px)`; }
}
app.js — start the trial
els.go.addEventListener('click', () => {
  if (phase !== 'idle') return;
  phase = 'confirming';
  els.vessel.setAttribute('aria-disabled', 'true');
  els.go.setAttribute('aria-busy', 'true'); els.go.setAttribute('aria-disabled', 'true');
  els.goLbl.textContent = seats > MAXS ? 'Sending…' : 'Starting trial…'; els.status.textContent = '';
  wake();
  setTimeout(() => {
    phase = 'done';
    els.go.removeAttribute('aria-busy'); els.go.classList.add('is-done');
    if (seats > MAXS) { els.goLbl.textContent = 'Request sent'; els.status.textContent = 'Thanks. Our team will email you within one business day.'; }
    else { els.goLbl.textContent = `Trial started · ${seatsText(seats)}`; els.status.textContent = `Your ${seats}-seat trial is ready. You won't be billed for 14 days.`; }
    els.undo.hidden = false; els.sales.hidden = true;
    wake();
  }, reduced ? 300 : 950);
});

Nothing runs when nothing moves: the loop asks for the next frame only while the spring is still travelling.

app.js — the loop
function frame(now) {
  raf = 0;
  const dt = Math.min(0.05, last ? (now - last) / 1000 : 1 / 120); last = now;
  const moving = step(dt, now);
  moveFallback();
  placeOverlays();
  if (moving) raf = requestAnimationFrame(frame); else last = 0;
}
function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
Stage 1 · the page and a CSS glassOpen ↗
The Tidal pricing page: the headline, the price and the trial button on the left; on the right a frosted CSS glass with a teal water column and a white price tag on its waterline.

2 · The real glass

Now the glass, in vessel.js: one fragment shader that traces every pixel analytically. Walls and floor are exact ray–cylinder and ray–plane roots; only the water surface is a height field. It is the level, tilted by the slosh, pulled into a funnel by the swirl, rippled by the ring wave, with the meniscus climbing the wall.

vessel.js — the water surface
float surfY(vec2 q){
  float r2 = dot(q, q)/(RI*RI), r = sqrt(r2);
  float y = uLevel + uTx*q.x + uTz*q.y;
  y -= uSwirl*14.*(0.5 - r2);                                 // the vortex funnel (volume kept)
  y += uSwirl*2.4*sin(3.*atan(q.y, q.x) + r*7. - uPhase)*r;   // spiral arms
  y += uRip*cos(r*17. - uRipT)*(1. - 0.45*r);                 // a ring wave from a touch
  y += 2.6*exp(-(1. - r)*RI/3.);                              // the meniscus climbing the glass
  return y;
}

The surface is searched only inside the thin slab it can occupy, coarse steps and then a bisection, so the cost does not grow with the glass.

vessel.js — find the surface (excerpt)
float tp = ta;
for (int i = 1; i <= 10; i++) {
  float t = mix(ta, tb, float(i)/10.);
  vec3 q = p + d*t;
  if ((q.y - surfY(q.xz))*sgn < 0.) {
    float a = tp, b = t;
    for (int k = 0; k < 6; k++) { float m = 0.5*(a + b); vec3 qq = p + d*m; if ((qq.y - surfY(qq.xz))*sgn < 0.) b = m; else a = m; }
    return b;
  }
  tp = t;
}
return -1.;

Every boundary reflects a Fresnel share of the studio and refracts the rest with the right pair of indices (air 1, glass 1.5, water 1.333). When there is no way out, total internal reflection turns the ray back.

vessel.js — cross an interface
void crossI(inout vec3 rd, inout int med, int nm, vec3 n, inout vec3 col, inout vec3 thr, float rk){
  if (dot(n, rd) > 0.) n = -n;
  float n1 = iorOf(med), n2 = iorOf(nm);
  float ci = clamp(-dot(rd, n), 0., 1.);
  float r0 = (n1 - n2)/(n1 + n2); r0 *= r0;
  float F = r0 + (1. - r0)*pow(1. - ci, 5.);
  vec3 rf = refract(rd, n, n1/n2);
  if (dot(rf, rf) < 1e-4) { rd = reflect(rd, n); return; }
  col += thr*F*env(reflect(rd, n))*rk;
  thr *= 1. - F;
  rd = normalize(rf); med = nm;
}

Inside the water, light is absorbed by Beer–Lambert law, so the teal deepens with the distance travelled, and a little is scattered back.

vessel.js — the water
vec3 tr = exp(-absW*best);
vec3 mid = p + rd*best*0.5;
float depth = smoothstep(BASE, BASE + 320., mid.y);   // absolute, so the column below the surface never changes with the level
vec3 glow = vec3(0.05, 0.2, 0.235)*(0.75 + 0.45*depth);
col += thr*glow*(1. - exp(-best*0.0045))*(1. - 0.75*uDis);

Where the water meets the glass, the meniscus mirrors the room in a thin silver line, and the floor gets a soft shadow with a teal caustic the water focuses.

vessel.js — the silver waterline
// the waterline: where the water meets the glass, the meniscus mirrors the room in a thin silver line
if (med == 1 && (bm == 2 || bm == 3) && bn.y == 0.) {
  float dd = p.y - surfY(p.xz);
  float band = smoothstep(-4.2, -1.6, dd)*smoothstep(2.2, 0.2, dd);
  float under = exp(-pow((dd + 6.)/2.6, 2.));
  col += thr*band*(vec3(0.85, 0.95, 0.98)*0.8 + vec3(0.1, 0.75, 0.85)*uFocus*1.6);
  thr *= (1. - band*0.82)*(1. - under*0.28*vec3(1., 0.55, 0.5));
}
vessel.js — the caustic
float ca = exp(-pow(d/(14. + 40.*t), 2.))*wPart*smoothstep(0.04, 0.3, t);
float crest = exp(-pow((t - wt + 0.06)/0.05, 2.))*exp(-pow(d/(30. + 30.*t), 2.));
c += vec3(0.32, 0.86, 0.95)*(ca*0.42 + crest*0.42)*(1. - 0.6*uDis);

The motion sells it. The surface leans against the flow and rocks back, two lightly damped oscillators at about 1.9 Hz; a change of level feeds a swirl that decays in about two seconds; every press starts a ring wave.

app.js — slosh
// slosh: the surface leans with the flow and rocks back (about 1.9 Hz, light damping)
const tzD = Math.max(-0.03, Math.min(0.03, -st.vel * 0.00006)), txD = Math.max(-0.02, Math.min(0.02, st.vel * 0.00002));
st.vtz += (-142 * (st.tz - tzD) - 2.3 * st.vtz) * s; st.tz += st.vtz * s;
st.vtx += (-142 * (st.tx - txD) - 2.3 * st.vtx) * s; st.tx += st.vtx * s;
app.js — a move feeds the swirl, a press the ring wave
function setTarget(t) {
  const d = Math.abs(t - st.tgt);
  st.tgt = t;
  if (reduced) { st.lev = t; st.vel = 0; return; }
  st.swirl = Math.min(1, st.swirl + d * 0.0026);
}
function ripple(e) { if (reduced) return; st.ripE = Math.max(st.ripE * Math.exp(-st.ripAge * 2.4), e); st.ripAge = 0; }
app.js — they die down
st.swirl *= Math.exp(-dt * 1.5); if (st.swirl < 0.012) st.swirl = 0;
st.phase += dt * (1.2 + 6 * st.swirl);
st.ripAge += dt; st.ripT += dt * 13;
st.rip = st.ripE * 2.1 * (1 - Math.exp(-st.ripAge * 12)) * Math.exp(-st.ripAge * 2.4);
if (st.ripAge > 0.1 && Math.abs(st.rip) < 0.03) { st.rip = 0; st.ripE = 0; }

A full-quality frame is too heavy to draw every frame, and it does not need to be. While the water moves, only the band the surface swept is redrawn, at one sample per pixel.

app.js — what changed
function dirtyRects(p, c) {
  if (ne(p.dis, c.dis)) return null;   // full
  const rects = [];
  // the success light band lives in the water column (and is piped up the rim)
  if (ne(p.bloom, c.bloom)) { rects.push(view.cylBox(0, Math.max(p.lev, c.lev) + amp(c) + 6, RO + 4)); rects.push(view.cylBox(HV - 10, HV + 2, RO + 4)); }
  const surf = ne(p.lev, c.lev) || ne(p.tx, c.tx, 1e-7) || ne(p.tz, c.tz, 1e-7) || ne(p.swirl, c.swirl) || ne(p.phase, c.phase) && c.swirl > 0 ||
    ne(p.rip, c.rip) || ne(p.ripT, c.ripT) && c.rip !== 0 || ne(p.focus, c.focus);
  // the surface, plus the two places light is piped through the glass to it: the base (seen from below by total
  // internal reflection) and the rim (rays entering the lip run down inside the wall)
  // (those two are thin light paths: while the water moves they are only queued for the refinement pass at rest)
  if (surf) { rects.push(union(surfBox(p), surfBox(c))); deferred.push(view.cylBox(0, BASE + 8, RO + 4), view.cylBox(HV - 10, HV + 2, RO + 4)); }
  if (ne(p.ghost, c.ghost) || (c.ghost > 0 && ne(p.ghostY, c.ghostY))) {
    if (p.ghost > 0) rects.push(view.cylBox(p.ghostY - 4, p.ghostY + 4, RO + 3));
    rects.push(view.cylBox(c.ghostY - 4, c.ghostY + 4, RO + 3));
  }
  return mergeRects(rects);
}

When everything is still, the changed areas are refined once at four samples per pixel, in small strips so no frame is long, and then the page sleeps.

app.js — refine at rest
function hqStep() {
  // one horizontal strip of an area that changed, at 4 spp
  const d = scene.dpr, r = hqRects[0], w = Math.max(1, (r[2] - r[0]) * d);
  const rows = Math.max(6, Math.floor(HQ_BUDGET / w)) / d;
  const strip = [r[0], r[1], r[2], Math.min(r[3], r[1] + rows)];
  scene.render(view, st, [strip], 4);
  if (strip[3] >= r[3] - 0.01) hqRects.shift(); else hqRects[0] = [r[0], strip[3], r[2], r[3]];
}
app.js — one frame
function frame(now) {
  raf = 0;
  const dt = Math.min(0.05, last ? (now - last) / 1000 : 1 / 120); last = now;
  const moving = step(dt, now); lastMoving = moving;
  if (scene) {
    const cur = snap();
    if (!lastDrawn) { scene.render(view, st, null, 1); lastDrawn = cur; hqRects = [canvasBox.slice()]; deferred.length = 0; }
    else {
      const rects = dirtyRects(lastDrawn, cur);
      if (!rects) { scene.render(view, st, null, 1); hqRects = [canvasBox.slice()]; deferred.length = 0; lastDrawn = cur; }
      else if (rects.length) { const rc = rects.map(clipC); scene.render(view, st, rc, 1); queueHQ(rc.concat(deferred.splice(0))); lastDrawn = cur; }
      else if (!moving && hqRects.length) hqStep();
    }
  } else moveFallback();
  placeOverlays();
  if (moving || (scene && hqRects.length && visible)) raf = requestAnimationFrame(frame); else last = 0;
}

Without WebGL 2, with ?nofx or after a lost context, the CSS glass from stage 1 takes over and the slider keeps working.

app.js — WebGL, or the CSS glass
if (!document.documentElement.classList.contains('no-fx')) {
  try { scene = new Scene(els.fx); } catch (e) { scene = null; document.documentElement.classList.add('no-fx'); }
}
Stage 2 · the real glassOpen ↗
The WebGL glass: a thick clear cylinder with a heavy base, teal water with a silver waterline, two tall highlights, etched graduations and a soft shadow with a teal caustic; the tag reads 42 seats.

3 · The finish

Hovering the glass shows where the water would go: a dark ghost waterline drawn by the shader and a small label with its price.

app.js — the hover ghost
hovering = true;
const g = seatsFromLevel(levelFromPointer(e));
if (g !== ghostSeats) {
  ghostSeats = g; st.ghostY = levelOf(g);
  els.ghost.textContent = g > MAXS ? '50+' : `${g} · ${fmt(total(g))}`;
}
wake();
vessel.js — the ghost line
float g = uGhost*clamp((1.1 - abs(p.y - uGhostY))/uPix + 0.5, 0., 1.)*smoothstep(1.5, 1.15, abs(th));
col = mix(col, vec3(0.004, 0.17, 0.22), g*0.85); thr *= 1. - g*0.85;

Keyboard focus lights the tag's ring and makes the meniscus glow, but only for keyboard focus, not after a click.

app.js — keyboard focus, not mouse focus
els.vessel.addEventListener('focus', () => { requestAnimationFrame(() => { kbFocus = performance.now() - pointerAt > 400 && els.vessel.matches(':focus-visible'); wake(); }); });
els.vessel.addEventListener('blur', () => { kbFocus = false; wake(); });
app.js — the states ease in and out
const fT = kbFocus && !locked() ? 1 : 0, gT = hovering && !dragging && !locked() && ghostSeats > 0 && ghostSeats !== seats ? 1 : 0, dT = disabled ? 1 : 0;
const f0 = st.focus, g0 = st.ghost, d0 = st.dis;
st.focus = ease(st.focus, fT, 9 * r, dt); st.ghost = ease(st.ghost, gT, 12 * r, dt); st.dis = ease(st.dis, dT, 6 * r, dt);
if (st.focus !== f0 || st.ghost !== g0 || st.dis !== d0 || st.focus !== fT || st.ghost !== gT || st.dis !== dT) moving = true;

Starting the trial sends a band of light up through the water, from the base to the surface, and the button turns into the confirmation.

app.js — the trial started
if (!reduced) { st.bloom = 0.0001; bloomOn = true; }
app.js — the band rises
if (bloomOn) { st.bloom += dt / 1.7; moving = true; if (st.bloom >= 1) { st.bloom = 0; bloomOn = false; } }
vessel.js — the light band
if (uBloom > 0.) {
  float yb = BASE - 30. + (uLevel - BASE + 60.)*uBloom;
  float bl = 0.;
  for (int k = 0; k < 3; k++) { vec3 s3 = p + rd*best*(0.2 + 0.3*float(k)); bl += exp(-pow((s3.y - yb)/24., 2.)); }
  col += thr*vec3(0.55, 1.0, 1.0)*bl*0.3*sin(PI*uBloom)*(1. - exp(-best*0.012));
}

The team size is stored under a versioned key, and older keys of the component are wiped before the first paint.

index.html — the storage wipe
// storage: versioned keys only; anything older is wiped. The page's theme is its own (light) and never follows the OS.
try {
  for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && /^dv75/.test(k) && k !== 'dv75.seats') localStorage.removeItem(k); }
} catch (e) {}
app.js — the stored team size
let saved = NaN; try { saved = parseInt(localStorage.getItem(KEY), 10); } catch (e) {}
const qSeats = parseInt(qs.get('seats'), 10);
let seats = qSeats >= 1 && qSeats <= 51 ? qSeats : saved >= 1 && saved <= 51 ? saved : 24;

A workspace admin can lock the seats: ?disabled frosts the glass, keeps the slider focusable with aria-disabled and says why.

app.js — disabled
if (disabled) { els.stage.classList.add('is-disabled'); els.vessel.setAttribute('aria-disabled', 'true'); els.locked.hidden = false; els.go.setAttribute('aria-disabled', 'true'); }

And on a first visit the water pours in from two seats and settles at the value, the tag counting up with it.

app.js — the first pour
// first visit: the water pours in from two seats and settles at the value (skipped for reduced motion / stills)
if (!reduced && !STILL && !disabled) {
  intro = true; pouring = true; introUntil = performance.now() + 320;
  st.lev = st.tgt = levelOf(2); st.swirl = 0.0; st.tz = 0;
}
Stage 3 · the finished sliderOpen ↗
The finished slider mid-drag: the water rushing up the glass with a swirl and a ring wave, the tag lifted and reading 42 seats, the volume rate badge next to the total.
Stage 3 · on a phoneOpen ↗
The slider on a phone: the headline, the price and the trial button above the glass; a finger drags the water up and the tag on the right reads the new team size.

Make it yours

  • Your prices are perSeat and total in app.js; the glass maps 1 to 50 seats from YMIN to YMAX and anything above to the brim.
  • Your liquid is absW in vessel.js: the absorption per unit of distance for red, green and blue. Lower red absorption gives amber, lower blue gives green.
  • Accessibility is already there: a real role="slider" with a value text, arrows, Page Up/Down, Home and End, 44 px targets, visible keyboard focus, every word in the DOM, prefers-reduced-motion (the level jumps, no swirl) and a CSS fallback without WebGL 2.

Tidal is a made-up brand. Everything on the page was made in code for this project: the glass, the water and the light are rendered by the shader, the fallback is CSS.

The one idea worth keeping

Redraw only what changed: while the water moves, the band the surface swept at one sample per pixel; at rest, those areas once more at four samples in small strips; then nothing at all. A ray-traced glass costs almost nothing when it stands still.