Building an Accessible Password Strength Meter

How do you show password strength as the user types without relying on colour, without spamming screen readers on every keystroke, and without turning the meter into a second, contradictory validation rule? This recipe builds a strength meter on the native <meter> element, scores the value with a lazily loaded estimator, announces the verdict at sensible moments, and keeps the field’s pass/fail decision where it belongs — in the Constraint Validation API — rather than in the meter’s colour.

When to Use a Strength Meter Instead of a Rule Checklist

A strength meter answers “how guessable is this?”, which is a continuous question. A live requirements checklist answers “which hard rules have I met?”, which is binary. They are complements, not alternatives, and mixing them up is the root of most confusing password UIs.

  • Use a meter on sign-up and change-password forms where you want to nudge users toward longer passphrases beyond the minimum.
  • Do not make the meter a gate. If “weak” blocks submission, it is a rule and belongs in the checklist with explicit text. The meter should advise; the minimum length and breach screen decide.
  • Skip the meter on sign-in forms. Strength of an existing password is irrelevant to authenticating, and scoring it leaks policy information.
Meter or checklist decision A decision tree that routes a password requirement either to the strength meter as advice or to the requirement checklist as a hard rule. Does failing this block submission? yes Checklist rule with text + setCustomValidity no Is it a continuous quality signal? yes Strength meter with text label no Hint text under the field
Anything that blocks submission is a rule with explicit text; the meter only ever advises.

Minimal Working Strength Meter Implementation

The markup uses a real <meter> so assistive technology gets a value, a range and a semantic role for free. The text label next to it carries the verdict in words, which is what satisfies WCAG 1.4.1 (Use of Color).

<label for="new-pw">New password</label>
<input id="new-pw" type="password" autocomplete="new-password"
       minlength="12" maxlength="128" required
       aria-describedby="pw-strength-text">
<div class="pw-strength">
  <meter id="pw-meter" min="0" max="4" low="2" high="3" optimum="4" value="0"
         aria-labelledby="pw-strength-label"></meter>
  <span id="pw-strength-label" class="visually-hidden">Password strength</span>
  <span id="pw-strength-text">Strength: not rated yet</span>
</div>
<div id="pw-strength-live" role="status" class="visually-hidden"></div>
type Score = 0 | 1 | 2 | 3 | 4;

const LABELS: Record<Score, string> = {
  0: "Very weak", 1: "Weak", 2: "Fair", 3: "Strong", 4: "Very strong",
};

const input = document.querySelector<HTMLInputElement>("#new-pw")!;
const meter = document.querySelector<HTMLMeterElement>("#pw-meter")!;
const text = document.querySelector<HTMLElement>("#pw-strength-text")!;
const live = document.querySelector<HTMLElement>("#pw-strength-live")!;

// Load the estimator only when the user focuses the field — keeps it off the critical path.
let estimator: Promise<(pw: string, inputs: string[]) => Score> | undefined;
function loadEstimator() {
  estimator ??= import("./strength-estimator").then((m) => m.score);
  return estimator;
}
input.addEventListener("focus", loadEstimator, { once: true });

// Fallback heuristic used until (or if) the estimator loads.
function quickScore(pw: string): Score {
  const len = [...pw].length;
  if (len < 8) return 0;
  if (len < 12) return 1;
  if (len < 16) return 2;
  return len < 20 ? 3 : 4;
}

let timer: number | undefined;
let lastAnnounced: Score | undefined;

async function update(announce: boolean): Promise<void> {
  const pw = input.value;
  const userInputs = [document.querySelector<HTMLInputElement>("[name=email]")?.value ?? ""];
  const score = pw ? await loadEstimator().then((fn) => fn(pw, userInputs)).catch(() => quickScore(pw)) : 0;
  if (input.value !== pw) return; // a newer keystroke superseded this run

  meter.value = score;
  text.textContent = pw ? `Strength: ${LABELS[score]}` : "Strength: not rated yet";
  meter.dataset.score = String(score);

  // Announce only on pauses/blur, and only when the verdict actually changed.
  if (announce && pw && score !== lastAnnounced) {
    live.textContent = `Password strength ${LABELS[score]}`;
    lastAnnounced = score;
  }
}

input.addEventListener("input", () => {
  void update(false);                           // visual update immediately
  window.clearTimeout(timer);
  timer = window.setTimeout(() => void update(true), 1200); // announce after a typing pause
});
input.addEventListener("blur", () => void update(true));

The estimator module wraps whatever scorer you choose. Passing the user’s email and name as userInputs matters: a passphrase built from the user’s own name should score low even if it is long.

// strength-estimator.ts — dynamically imported
import { zxcvbn, zxcvbnOptions } from "@zxcvbn-ts/core";
import * as common from "@zxcvbn-ts/language-common";

zxcvbnOptions.setOptions({ dictionary: { ...common.dictionary }, graphs: common.adjacencyGraphs });

export function score(pw: string, userInputs: string[]): 0 | 1 | 2 | 3 | 4 {
  return zxcvbn(pw, userInputs).score;
}
Visual versus announced meter updates A timeline of keystrokes showing the meter redrawing on every keystroke while the screen-reader announcement waits for a 1.2 second typing pause. Keystrokes c o r r e c Meter redraw weak weak fair fair strong Announcement pause 1.2 s pause 1.2 s 0 ms 1000 ms 2000 ms 3000 ms 4000 ms
The meter redraws on every keystroke, but the live region only speaks after a pause, so the typing echo is never interrupted.

Strength Meter Option Reference

Option Type Default Purpose
meter.min / max number 0 / 4 Match the estimator’s score range exactly
meter.low / high number 2 / 3 Thresholds browsers use to tint the native meter
meter.optimum number 4 Tells the browser that higher is better
Announce delay number (ms) 1200 Pause after the last keystroke before speaking
userInputs string[] [email] Personal data that should lower the score
Estimator load trigger event focus Defers the dictionary download until needed
quickScore fallback (pw) => Score length buckets Keeps the meter working offline or on estimator failure

Browsers colour the native <meter> using low, high and optimum, but that colour is decorative. Style it through meter::-webkit-meter-optimum-value and meter::-moz-meter-bar if you want brand colours, and always keep the text label visible.

Estimator cost on the critical path A bar chart comparing the bytes shipped up front for three strength-estimation approaches. Length heuristic only 0.3 kB gzip Estimator, lazy on focus 1.2 kB gzip Estimator bundled eagerly 380 kB gzip
Lazy-loading the dictionary on focus keeps the full estimator off the initial page load; the length heuristic costs almost nothing.

Verification Steps

import { test, expect } from "@playwright/test";

test("meter reflects strength in text and value", async ({ page }) => {
  await page.goto("/signup");
  const field = page.getByLabel("New password");
  await field.pressSequentially("correct horse battery staple", { delay: 20 });
  await expect(page.locator("#pw-strength-text")).toHaveText(/Strength: (Strong|Very strong)/);
  await expect(page.locator("#pw-meter")).toHaveJSProperty("value", 4);
  // Announcement arrives after the pause.
  await expect(page.getByRole("status")).toHaveText(/Password strength/, { timeout: 3000 });
});

Edge Cases and Failure Modes

The meter contradicts the rules. A 10-character random string might score “Strong” while failing a 12-character minimum. Users see green and a rejection at the same time. Cap the displayed score at 1 (“Weak”) whenever a hard rule is unmet, so the meter never looks happier than the verdict.

const rulesMet = input.validity.valid || (input.validity.customError === false && !input.validity.tooShort);
const displayed = (rulesMet ? score : Math.min(score, 1)) as Score;

Estimator fails to load. A blocked chunk or offline visit makes the dynamic import reject. The catch(() => quickScore(pw)) fallback keeps the meter meaningful; never leave it stuck at “not rated”.

Announcing on every keystroke. Wiring the live region inside the input handler without the pause produces a flood of announcements that interrupts the typing echo — exactly the problem the accessible toast notifications guide warns about for transient messages. Always debounce announcements separately from redraws.

Reveal toggle changes nothing. When the user shows the password with an accessible show/hide toggle, the input type changes but the value does not; do not reset lastAnnounced or re-announce strength on toggle.

Styling the meter Element in Light, Dark and Forced-Colours Themes

The native meter ships with engine-specific pseudo-elements, so brand styling needs one rule per engine plus a reset of the default appearance. Drive the colours from custom properties so a dark theme only swaps variables, and let forced-colours mode fall back to system colours instead of fighting it — in that mode the text label is doing the real work anyway.

.pw-strength meter {
  appearance: none;
  inline-size: 100%;
  block-size: 0.5rem;
  border-radius: 999px;
  background: var(--meter-track, #e2e8f0);
}
.pw-strength meter::-webkit-meter-bar { background: var(--meter-track, #e2e8f0); border-radius: 999px; }
.pw-strength meter::-webkit-meter-even-less-good-value { background: var(--meter-weak, #dc2626); }
.pw-strength meter::-webkit-meter-suboptimum-value { background: var(--meter-fair, #d97706); }
.pw-strength meter::-webkit-meter-optimum-value { background: var(--meter-strong, #059669); }
.pw-strength meter::-moz-meter-bar { background: var(--meter-strong, #059669); }

@media (forced-colors: active) {
  .pw-strength meter { forced-color-adjust: auto; } /* system Highlight colours */
}

Because Firefox exposes a single ::-moz-meter-bar, give it a colour per score with the data-score attribute the script already sets, for example meter[data-score="1"]::-moz-meter-bar. Test contrast of the bar against its track as a non-text graphic: WCAG 1.4.11 asks for 3:1, which the pale default tracks often miss in dark themes.

Why the Native meter Element Beats a Styled div

Many strength meters are four coloured <div> bars whose “fill” is a background colour. To a screen reader that is nothing at all — no role, no value — and in forced-colours mode the backgrounds vanish, leaving four identical outlines. The <meter> element exposes an implicit meter role with aria-valuenow, aria-valuemin and aria-valuemax derived from its attributes, survives forced-colours rendering with a system-drawn bar, and needs no ARIA of your own. If the design insists on segmented bars, draw them inside a visually restyled <meter> rather than replacing it, and keep the adjacent text label as the primary carrier of meaning.

Frequently Asked Questions

Should a weak password strength score block form submission?

No. If something blocks submission it is a rule and needs explicit text in the requirements checklist plus setCustomValidity(). The meter should advise users toward stronger passwords, while minimum length and breach screening make the pass/fail decision.

Why use the meter element instead of a progress bar?

A <progress> element means "task completion", which is the wrong semantics for a quality gauge. <meter> represents a scalar within a known range, exposes value, min and max to assistive technology, and supports low/high/optimum thresholds.

How often should strength be announced to screen reader users?

Only after a typing pause of around a second and on blur, and only when the verdict changed. Announcing on every keystroke interrupts the character echo that users rely on to know what they typed.

Do I need a full dictionary-based estimator?

Not for the meter to be useful. A length-based heuristic already steers users toward passphrases. A dictionary estimator adds value for catching common words and personal data, and should be lazy-loaded on focus so it never slows the initial page.

← Back to Password Validation Patterns