Library / Tutorial

Vial strength,
step by step.

A password-strength meter that is a glass vial. Every key raises the glowing liquid, the colour walks from ruby to emerald and the new colour curls into the old one; the glass, the cap and the light are traced by one shader. Every word stays DOM. Three stages, each a live page you can run.

3Stages
2Canvases
0Libraries

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.

index.html — the password, the meter and the rules
<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 &amp; 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.

strength.js — the score
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 };
}
strength.js — the words on the meter
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.

strength.js — a readable accent
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.

app.js — on every key
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.

app.js — submit
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.

vial.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];
}
app.js — WEAK to STRONG, level with the etched ticks
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.

vial.css — the liquid
.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); }
app.js — the CSS liquid follows the level
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.

app.js — the heavy spring
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;
}
Stage 1 · the form and a CSS vialOpen ↗
The Verdance sign-up page: the headline, the email and password fields, the strength and the four requirements on the left; on the right a CSS vial with a brass cap and an amber liquid column next to the WEAK to STRONG labels.

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.

vial.js — the 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*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.

vial.js — the dye front
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.

vial.js — the colour at a point
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.

vial.js — the glowing liquid
// 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.

vial.js — the meniscus
// 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.

app.js — a new dye front
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.

app.js — slosh
// 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;
app.js — the front sinks
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.

app.js — the accent, cheaply
// 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.

app.js — what changed
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.

app.js — refine at rest
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]];
}
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); 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.

app.js — WebGL, or the CSS vial
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'); }
}
Stage 2 · the real vialOpen ↗
The WebGL vial: thick clear glass under a knurled brass cap, the liquid emerald at the top curling down through lime-yellow into orange, a silver meniscus, etched graduations and a coloured shadow with a bright caustic line on the table.

3 · The finish

On open the page shows what it does: a demo password types itself in, and the liquid rises and turns emerald.

app.js — the showcase
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.

app.js — the ring
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.

app.js — lean toward the pointer
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();
});
app.js — press to swirl
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.

app.js — the account is created
if (!reduced) { st.bloom = 0.0001; bloomOn = true; }
app.js — the band rises
if (bloomOn) { st.bloom += dt / 1.8; moving = true; if (st.bloom >= 1) { st.bloom = 0; bloomOn = false; } }
vial.js — the light band
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.

index.html — the storage wipe
// 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) {}
app.js — seen, and ?demo=0
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.

app.js — disabled
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.';
}
Stage 3 · the finished meterOpen ↗
The finished meter: the password typed to STRONG, the emerald curling down through the lime into the amber, the STRONG label lit, the accent and the focus ring in the colour of the liquid.
Stage 3 · on a phoneOpen ↗
The meter on a phone: the headline and the requirements beside a tall vial, the email and password fields below; the password filled, the liquid emerald at the top curling down into the lower colours.

Make it yours

  • Your rules are score() and MARKS in strength.js: the marks are where WEAK, FAIR, GOOD and STRONG sit on the vial, and MIN_OK is the weakest password the form accepts.
  • Your colours are STOPS in strength.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 with aria-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.

The one idea worth keeping

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.