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.
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;
}
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.
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.
Related Guides
- Password Validation Patterns — where the meter fits in the full password pipeline.
- Live Password Requirements Checklist — the hard rules the meter must never contradict.
- Checking Passwords Against Breached Lists — the screen that actually rejects compromised passwords.
- Debouncing Real-Time Validation Input — the timing technique behind the announcement delay.