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.
<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.
.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); }
.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.
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.
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;
}
// ---------- 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.
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 });
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.
// 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.
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)`; }
}
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.
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); }

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.
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.
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.
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.
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.
// 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));
}
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.
// 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;
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; }
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.
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.
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]];
}
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.
if (!document.documentElement.classList.contains('no-fx')) {
try { scene = new Scene(els.fx); } catch (e) { scene = null; document.documentElement.classList.add('no-fx'); }
}

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.
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();
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.
els.vessel.addEventListener('focus', () => { requestAnimationFrame(() => { kbFocus = performance.now() - pointerAt > 400 && els.vessel.matches(':focus-visible'); wake(); }); });
els.vessel.addEventListener('blur', () => { kbFocus = false; wake(); });
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.
if (!reduced) { st.bloom = 0.0001; bloomOn = true; }
if (bloomOn) { st.bloom += dt / 1.7; moving = true; if (st.bloom >= 1) { st.bloom = 0; bloomOn = false; } }
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.
// 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) {}
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.
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.
// 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;
}


Make it yours
- Your prices are
perSeatandtotalinapp.js; the glass maps 1 to 50 seats fromYMINtoYMAXand anything above to the brim. - Your liquid is
absWinvessel.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.
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.