This is the whole build of the vial strength meter, in the order it was written: the sign-up page of a made-up company, Verdance. The password's strength is the liquid in a glass vial with a brass cap. Every key raises it on a heavy spring, the colour walks from ruby through amber and lime to emerald, and each new colour curls down into the old one like dye dropped in water. 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 vial is two
transparent WebGL 2 canvases, one for the glass and one for the room it stands in; every word (the form, the strength,
the requirements, the WEAK to STRONG labels) stays real DOM, and the vial itself is decoration: the strength is a real
role="meter" next to the field.
1 · The form, the score, and a vial in CSS
The form is a real <form>. The password field describes itself with the meter, its error and a
one-line summary of the rules, so a screen reader hears the strength without seeing the vial.
<div class="f f-pw">
<div class="lrow"><label for="pw">Password</label><span class="caps" id="caps" hidden>Caps Lock is on</span></div>
<div class="inp pwinp">
<input id="pw" name="new-password" type="password" autocomplete="new-password" spellcheck="false" autocapitalize="off" aria-describedby="meter pw-err reqs-sr" required>
<button type="button" class="show" id="show" aria-pressed="false" aria-controls="pw">Show</button>
</div>
</div>
<div class="meter" id="meter" role="meter" aria-labelledby="meter-l" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="Empty">
<span id="meter-l">Strength</span><span class="err" id="pw-err"></span><b id="lvl">Empty</b>
</div>
<ul class="reqs" id="reqs" aria-label="Password requirements">
<li data-r="len"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7.25"/><path d="M4.6 8.3l2.2 2.2 4.6-4.7"/></svg><span>12+ characters</span><span class="sr st"> (not yet)</span></li>
<li data-r="case"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7.25"/><path d="M4.6 8.3l2.2 2.2 4.6-4.7"/></svg><span>Upper & lower case</span><span class="sr st"> (not yet)</span></li>
<li data-r="num"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7.25"/><path d="M4.6 8.3l2.2 2.2 4.6-4.7"/></svg><span>A number</span><span class="sr st"> (not yet)</span></li>
<li data-r="sym"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7.25"/><path d="M4.6 8.3l2.2 2.2 4.6-4.7"/></svg><span>A symbol</span><span class="sr st"> (not yet)</span></li>
</ul>
The score is plain arithmetic on the string, and nothing leaves the page: the size of the character pool times an effective length (repeats and runs of three count for less), minus common passwords and the email's own name.
export function score(pw, email = '') {
const rules = { len: pw.length >= 12, case: /[a-z]/.test(pw) && /[A-Z]/.test(pw), num: /\d/.test(pw), sym: /[^A-Za-z0-9]/.test(pw) };
if (!pw) return { s: 0, rules, flags: [] };
let pool = 0;
if (/[a-z]/.test(pw)) pool += 26;
if (/[A-Z]/.test(pw)) pool += 26;
if (/\d/.test(pw)) pool += 10;
if (/[^A-Za-z0-9]/.test(pw)) pool += 33;
const lb = Math.log2(Math.max(pool, 10));
const low = pw.toLowerCase();
let eff = 0;
for (let i = 0; i < pw.length; i++) {
const c = low[i], p = i ? low[i - 1] : '', pp = i > 1 ? low[i - 2] : '';
eff += c === p ? 0.25 : pp && inRun(pp, p) && inRun(p, c) ? 0.35 : 1; // only runs of three (abc, 123, qwe) count
}
let bits = eff * lb;
const flags = [];
for (const w of COMMON) if (low.includes(w)) { bits -= w.length * lb * 0.85; flags.push('common'); break; }
const name = (email.split('@')[0] || '').toLowerCase().split(/[^a-z0-9]+/).filter((x) => x.length >= 3);
for (const n of name) if (low.includes(n)) { bits -= n.length * lb * 0.8; flags.push('email'); break; }
const s = Math.max(0.03, Math.min(1, bits / 120));
return { s, rules, flags };
}
export function label(s, empty) {
if (empty) return 'Empty';
if (s >= VERY) return 'Very strong';
if (s >= MARKS[3]) return 'Strong';
if (s >= MARKS[2]) return 'Good';
if (s >= MARKS[1]) return 'Fair';
if (s >= MARKS[0]) return 'Weak';
return 'Too weak';
}
The accent colour (the focus ring, the italic line, the logo) follows the liquid, but text needs contrast: the ramp colour is normalised and lifted until it reads at 4.5:1 on the graphite page.
export function accentHex(s) {
const c = colAt(s), m = Math.max(...c);
return '#' + c.map((x) => {
const v = Math.pow(Math.max(0, x / m), 1 / 2.2); // normalised hue, display space
return Math.round(Math.min(1, 0.22 + v * 0.76) * 255).toString(16).padStart(2, '0');
}).join('');
}
Every input runs one function: score, the level word, the meter's value, the four requirement ticks, the lit labels, and a polite announcement 0.7 s after typing pauses.
function update() {
const pw = els.pw.value, r = score(pw, els.em.value);
const s = pw ? r.s : 0;
emptyPw = !pw;
sTarget = s;
// the accent (focus ring, the italic line, the logo's liquid and the CSS vial's liquid) takes the strength's colour
document.documentElement.style.setProperty('--acc', accentHex(s));
const lab = label(s, emptyPw);
els.lvl.textContent = lab;
els.meter.setAttribute('aria-valuenow', String(Math.round(s * 100)));
els.meter.setAttribute('aria-valuetext', lab);
reqLis.forEach((li) => { const okR = !!r.rules[li.dataset.r]; li.classList.toggle('ok', okR); li.querySelector('.st').textContent = okR ? ' (done)' : ' (not yet)'; });
grads.forEach((g, i) => g.classList.toggle('on', !emptyPw && s >= MARKS[i]));
if (els.pw.getAttribute('aria-invalid') === 'true' && s >= MIN_OK) clearErr('pw');
clearTimeout(announceT);
announceT = setTimeout(() => { if (document.activeElement === els.pw) els.announce.textContent = `Password strength: ${lab}`; }, 700);
wake();
}
Too weak or a bad email is said inline on that field, the focus moves there and the field shakes. A good one turns the button busy, then into the confirmation.
els.form.addEventListener('submit', (e) => {
e.preventDefault();
if (phase !== 'idle') return;
const emOk = EMAIL.test(els.em.value.trim());
const s = els.pw.value ? score(els.pw.value, els.em.value).s : 0;
let first = null;
if (!emOk) { setErr('em', els.em.value.trim() ? 'Enter a valid email' : 'Enter your email'); first = first || els.em; } else clearErr('em');
if (s < MIN_OK) {
setErr('pw', !els.pw.value ? 'Enter a password' : 'Too weak · aim for Fair');
first = first || els.pw;
} else clearErr('pw');
if (first) {
els.status.textContent = '';
if (!reduced) { els.form.classList.remove('shake'); void els.form.offsetWidth; els.form.classList.add('shake'); }
first.focus({ preventScroll: true }); wake(); return;
}
phase = 'busy';
els.form.classList.add('is-busy');
[els.em, els.pw, els.show].forEach((x) => (x.disabled = true));
els.go.setAttribute('aria-busy', 'true'); els.go.setAttribute('aria-disabled', 'true');
els.goLbl.textContent = 'Creating account…'; els.status.textContent = '';
wake();
setTimeout(() => {
phase = 'done';
els.form.classList.remove('is-busy'); els.form.classList.add('is-done');
els.go.removeAttribute('aria-busy');
els.goLbl.textContent = 'Account created';
const name = (els.em.value.split('@')[0] || '').split(/[._-]/)[0];
els.status.textContent = `Welcome to Verdance${name ? ', ' + name.charAt(0).toUpperCase() + name.slice(1) : ''}. Check ${els.em.value.trim()} to confirm.`;
els.again.hidden = false;
wake();
}, reduced ? 300 : 950);
});
A small camera projects a point of the vial (in world units) to CSS pixels in the hero. The labels, the CSS vial and, in stage 2, both shaders use the same numbers, 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];
}
grads.forEach((g, i) => {
const y = levelOf(MARKS[i]), p = [view.project([RO, y, 0])[0], view.project([0, y, RO])[1]]; // beside the glass, level with the etched tick
g.style.fontSize = fs.toFixed(1) + 'px';
g.style.transform = `translate(${(p[0] + Math.max(10, k * 24)).toFixed(1)}px, ${p[1].toFixed(1)}px) translateY(-50%)`;
});
The first vial is CSS: a frosted glass, a cap, and a liquid column painted in the accent colour.
.cv-liquid { left: 7%; right: 7%; border-radius: 3px 3px 12px 12px / 3px 3px 8px 8px; background: linear-gradient(to top, color-mix(in srgb, var(--acc) 70%, #000 10%), var(--acc)); box-shadow: 0 0 40px -6px var(--acc); transition: opacity .3s; border-top: 2px solid rgba(255, 255, 255, .6); }
function moveFallback() {
const g = view.cylBox(0, CB, RO, 0), c = view.cylBox(CB, CT, RC, 0);
const top = view.project([0, st.lev, RO])[1], bot = view.project([0, BASE, RO])[1];
els.cvLiquid.style.top = (top - c[1]).toFixed(1) + 'px';
els.cvLiquid.style.height = Math.max(0, bot - top).toFixed(1) + 'px';
els.cvLiquid.style.opacity = st.lev > BASE + 1 ? '1' : '0';
void g;
}
The level never jumps: a heavy spring carries it to the target, integrated in small steps so it is the same at any frame rate, and the loop sleeps when it arrives.
function step(dt) {
const tgt = levelOf(sTarget);
if (reduced) { st.lev = 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 = 38 * (tgt - st.lev) - 8.6 * st.vel;
st.vel += a * s; st.lev += st.vel * s;
}
if (Math.abs(tgt - st.lev) < 0.03 && Math.abs(st.vel) < 0.05) { st.lev = tgt; st.vel = 0; }
}
return st.lev !== tgt || st.vel !== 0;
}

2 · The real vial
Now the vial, in vial.js: one fragment shader traces every pixel analytically through the brass cap,
the outer wall, the glass, the inner wall, the liquid, the far wall, the heavy base and the table. Only the liquid's
surface is a height field: the level, tilted by the slosh, with the meniscus climbing the glass.
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*16.*(0.5 - r2); // the vortex funnel (volume kept)
y += uSwirl*2.6*sin(3.*atan(q.y, q.x) + r*7. - uPhase*3.)*r; // spiral arms
y += 2.4*exp(-(1. - r)*RI/2.6)*liquidOn(); // the meniscus climbing the glass
return y;
}
The dye is the idea of the piece. Every change of strength starts a front at the surface that sinks through the column; above it the new colour, below it the old one, and two layers of noise warp the front so it curls.
float dyeW(vec3 p, out float n2){
float n1 = vn(vec2(p.x*0.011 + p.z*0.007 + uPhase*0.11, p.y*0.0062 - uPhase*0.17));
n2 = vn(vec2(p.z*0.013 - p.x*0.009 + 4.1, p.y*0.0115 + 3.7 + uPhase*0.07));
float yF = uLevel + 70. - (uLevel - BASE + 200.)*uMix;
return smoothstep(yF - 70., yF + 70., p.y + (n1 - 0.5)*130. + (n2 - 0.5)*50.);
}
The two colours are mixed as colours, not walked along the ramp, so a drop from emerald back to ruby never passes through a yellow band.
vec3 liqCol(vec3 p){
float n2; float w = dyeW(p, n2);
float g = gradS(p) - (n2 - 0.5)*0.035;
if (w >= 0.999) return colAt(uSNew - g);
if (w <= 0.001) return colAt(uSOld - g);
return mix(colAt(uSOld - g), colAt(uSNew - g), w);
}
Inside the liquid the light that the dye does not pass is absorbed, and the dye glows: brighter near the base, where light is piped up through the glass, and a little brighter on the left, where the key light comes from.
// the dyed liquid: absorption of what the dye does not pass, plus its glow and the pale veils
vec3 a = p + rd*best*0.3, b = p + rd*best*0.72;
vec3 lc = 0.5*(liqCol(a) + liqCol(b));
float wv = 0.5*(wisp(a) + wisp(b));
float lift = 1. + 0.35*smoothstep(BASE + 140., BASE, 0.5*(a.y + b.y)); // light piped up from the base
lift *= 0.84 + 0.28*(0.5 - 0.5*clamp((a.x + b.x)*0.5/RI, -1., 1.)); // the key light comes from the left: a rounder column
vec3 tr = exp(-((vec3(1.) - lc)*0.032 + 0.001)*best);
vec3 emit = lc*(0.98*lift + 0.6*wv) + mix(lc, vec3(1.), 0.3)*wv*0.1;
col += thr*emit*(1. - exp(-best*0.016))*(1. - 0.8*uDis);
Where the liquid meets the glass a thin silver line with a dark lip; it glows while the password has the focus.
// the meniscus: where the liquid meets the glass, a thin silver line with a dark lip; it glows on focus
if (med == 1 && (bm == 2 || bm == 3) && bn.y == 0. && liquidOn() > 0.) {
float dd = p.y - surfY(p.xz);
float band = smoothstep(-3.6, -1.4, dd)*smoothstep(2., 0.2, dd);
float under = exp(-pow((dd + 5.)/2.4, 2.));
vec3 lc = liqCol(vec3(p.x, uLevel - 4., p.z));
col += thr*band*(vec3(0.8, 0.86, 0.84)*0.75 + mix(lc, vec3(1.), 0.3)*(0.25 + uFocus*1.5));
thr *= (1. - band*0.8)*(1. - under*0.3);
}
In the page, a new strength starts a new front: what is in the column now becomes the old colour, and the key gives the surface a small kick.
if (Math.abs(s - sTarget) > 1e-4) {
// a new dye front starts at the surface; what is in the column now becomes the "old" colour
st.sOld = st.sOld + (st.sNew - st.sOld) * easeMix(st.mix);
st.sNew = s; st.mix = reduced ? 1 : 0;
if (kick && !reduced) { st.vtx += (s > sTarget ? 1 : -1) * (0.05 + 0.04 * Math.random()) * (Math.random() < 0.5 ? -1 : 1); st.vtz += (Math.random() - 0.5) * 0.06; }
}
The surface leans against the flow and rocks back, two lightly damped oscillators at about 1.75 Hz, and each front takes 1.6 s to sink.
// slosh: the surface leans with the flow and rocks back (≈ 1.75 Hz, light damping)
const tzD = Math.max(-0.03, Math.min(0.03, -st.vel * 0.00007)), txD = Math.max(-0.02, Math.min(0.02, st.vel * 0.00003));
st.vtz += (-120 * (st.tz - tzD) - 2.1 * st.vtz) * s; st.tz += st.vtz * s;
st.vtx += (-120 * (st.tx - txD) - 2.1 * st.vtx) * s; st.tx += st.vtx * s;
if (st.mix < 1) { st.mix = Math.min(1, st.mix + dt / 1.6); if (st.mix >= 1) st.sOld = st.sNew; }
The accent is a custom property on the root, so every change restyles the whole document. It steps in sixtieths and CSS transitions smooth the rest.
// the accent is a :root custom property (a whole-document style recalc): step it in 1/60ths, CSS transitions smooth it
const acc = hexOf(Math.round(sVis * 60) / 60);
if (acc !== accShown) { accShown = acc; document.documentElement.style.setProperty('--acc', acc); }
A ray-traced vial is too heavy to draw at full quality every frame, and it does not need to be. A change of level or dye redraws the vial at one sample per pixel; a slosh or the focus glow only the band around the surface.
function dirty(p, c) {
const glowCh = ne(p.glow[0], c.glow[0]) || ne(p.glow[1], c.glow[1]) || ne(p.glow[2], c.glow[2]);
if (ne(p.lev, c.lev) || ne(p.phase, c.phase) || ne(p.sNew, c.sNew) || ne(p.sOld, c.sOld) || ne(p.mix, c.mix) || ne(p.dis, c.dis) || ne(p.fill, c.fill) || glowCh || ne(p.halo, c.halo)) return { full: true, bg: true };
const rects = [];
if (ne(p.tx, c.tx, 1e-7) || ne(p.tz, c.tz, 1e-7) || ne(p.focus, c.focus)) rects.push(union(surfBox(p), surfBox(c)));
return { full: false, bg: false, rects: 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() {
const d = scene.dpr, r = hqRects[0], w = Math.max(1, (r[2] - r[0]) * d);
const rows = Math.max(4, 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); lastMoving = moving;
if (scene) {
const cur = snap();
if (!lastDrawn) { scene.renderBg(view, st); scene.render(view, st, null, 1); lastDrawn = cur; hqRects = [vialBox.slice()]; }
else {
const dd = dirty(lastDrawn, cur);
if (dd.bg) scene.renderBg(view, st);
if (dd.full) { scene.render(view, st, null, 1); hqRects = [vialBox.slice()]; lastDrawn = cur; }
else if (dd.rects.length) { const rc = dd.rects.map(clipC); scene.render(view, st, rc, 1); queueHQ(rc); lastDrawn = cur; }
else if (!moving && hqRects.length) hqStep();
}
} else moveFallback();
if (moving || (scene && hqRects.length && visible)) raf = requestAnimationFrame(frame); else last = 0;
}
Without WebGL 2, with ?nofx or after a lost context, the CSS vial from stage 1 takes over and the form
keeps working.
if (!document.documentElement.classList.contains('no-fx')) {
try { scene = new Scene(els.fx, els.bgfx); } catch (e) { scene = null; document.documentElement.classList.add('no-fx'); }
}

3 · The finish
On open the page shows what it does: a demo password types itself in, and the liquid rises and turns emerald.
function runIntro(from) {
intro = true;
let i = from;
const tick = () => {
if (!intro) return;
i++; els.pw.value = SHOWCASE.slice(0, i); update();
if (i < SHOWCASE.length) introT = setTimeout(tick, 70 + Math.random() * 40); else intro = false;
};
introT = setTimeout(tick, 260);
}
While the showcase is "being typed" the field wears its focus ring, until the visitor does anything at all.
if (demo && !disabled) {
demoRing = true; focusOn = true; els.pw.closest('.inp').classList.add('still-focus');
['pointerdown', 'keydown', 'focusin'].forEach((t) => addEventListener(t, endDemoRing, true));
}
The vial answers the pointer: its surface leans toward a fine pointer, and a press swirls the liquid into a vortex (no splash) and puts the focus in the password.
els.hit.addEventListener('pointermove', (e) => {
if (e.pointerType === 'touch' || reduced || disabled) return;
const r = els.hero.getBoundingClientRect();
hoverLean = Math.max(-1, Math.min(1, (e.clientX - r.left - vialC) / (vialHW * 2.2)));
wake();
});
els.hit.addEventListener('pointerdown', (e) => {
if (disabled) return;
stopIntro();
if (!reduced && st.lev > BASE + 2) { st.swirl = Math.min(1, st.swirl + 0.75); st.vtz -= 0.05; }
if (e.pointerType !== 'touch' && phase === 'idle') { e.preventDefault(); els.pw.focus({ preventScroll: true }); }
A created account sends a band of light up through the liquid, from the base to the surface.
if (!reduced) { st.bloom = 0.0001; bloomOn = true; }
if (bloomOn) { st.bloom += dt / 1.8; moving = true; if (st.bloom >= 1) { st.bloom = 0; bloomOn = false; } }
if (uBloom > 0.) {
float yb = BASE - 40. + (uLevel - BASE + 90.)*uBloom, 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)/22., 2.)); }
col += thr*mix(lc, vec3(1.), 0.55)*bl*0.42*sin(PI*uBloom)*(1. - exp(-best*0.012));
}
Only one thing is stored, whether the showcase has been seen (a repeat visit starts it further on), under a versioned key; older keys are wiped before the first paint. The password is never stored.
// storage: versioned keys only (dv93.*); anything older is wiped. Passwords are never stored. The page's theme is its
// own (dark) and never follows the OS.
try {
for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && /^dv93/.test(k) && k !== 'dv93.v1.seen') localStorage.removeItem(k); }
} catch (e) {}
let seen = false; try { seen = localStorage.getItem(SEEN) === '1'; localStorage.setItem(SEEN, '1'); } catch (e) {}
const demo = qs.get('demo') !== '0';
const animate = demo && !reduced && !STILL && !disabled;
const from = animate ? (seen ? 'Kestrel-harbour'.length : 'Kestrel'.length) : SHOWCASE.length;
When sign-ups are paused, ?disabled disables the fields and the button, says why, and frosts the
glass and the liquid grey.
if (disabled) {
els.form.classList.add('is-disabled');
[els.em, els.pw, els.show].forEach((x) => (x.disabled = true));
els.go.setAttribute('aria-disabled', 'true');
els.status.textContent = 'Sign-ups are paused for maintenance. Please try again later.';
}


Make it yours
- Your rules are
score()andMARKSinstrength.js: the marks are where WEAK, FAIR, GOOD and STRONG sit on the vial, andMIN_OKis the weakest password the form accepts. - Your colours are
STOPSinstrength.js: one ramp in linear RGB that the shader, the shadow and the accent all read. - Accessibility is already there: a real form with labelled fields,
role="meter"with a value text, inline errors witharia-invalid, a polite announcement, Show / Hide as a toggle, Caps Lock, 44 px targets,prefers-reduced-motion(the level jumps, no slosh, no dye front) and a CSS fallback without WebGL 2.
Verdance is a made-up brand. Everything on the page was made in code for this project: the glass, the cap, the liquid and the light are rendered by the shaders, the fallback is CSS. Nothing you type is sent or stored.
Give a number a material. The meter is still a number from 0 to 1, but the old value stays in the column while the new one curls in, so the change itself is what you see.