A typical password field shows a coloured bar that turns from red to green and a list of rules whose ticks change colour as you type — which tells a colour-blind user nothing, tells a screen-reader user nothing unless it announces on every keystroke, and when it does announce on every keystroke makes the field nearly unusable.
Password guidance is valuable, and it can be accessible: show the requirements before the user starts, track each one in text as well as colour, announce only when the overall status changes, and let users see what they typed. This page, part of ARIA live regions for form errors, builds that component and draws the line between helpful requirements and rules that make passwords worse.
Context and prerequisites
Two kinds of feedback, often conflated:
- Requirements — hard rules the password must meet to be accepted (“at least 12 characters”, “not a password found in known breaches”). These are validation: pass or fail.
- Strength — an estimate of how guessable the password is, typically from an estimator such as zxcvbn. This is advice, not validation.
Current guidance (for example NIST SP 800-63B) favours length minimums, checking against breached-password lists, and allowing all printable characters and pasting — while discouraging composition rules like “must include a symbol” and periodic forced changes. Fewer, better rules also make the accessible UI simpler: there is less to track and announce.
The core pattern: a text-first checklist with status-change announcements
<label for="new-password">Create a password</label>
<p id="pw-reqs-intro">Your password must:</p>
<ul id="pw-reqs" aria-labelledby="pw-reqs-intro">
<li data-rule="length"><span class="state">Not met: </span>be at least 12 characters</li>
<li data-rule="breach"><span class="state">Not checked: </span>not be a commonly used password</li>
</ul>
<div class="pw-row">
<input id="new-password" name="new-password" type="password" autocomplete="new-password"
aria-describedby="pw-reqs-intro pw-reqs pw-strength">
<button type="button" id="pw-toggle" aria-pressed="false" aria-controls="new-password">Show password</button>
</div>
<p id="pw-strength">Strength: <span class="strength-text">not rated yet</span></p>
type RuleState = "met" | "not-met" | "unchecked";
export function wirePasswordField(
input: HTMLInputElement,
list: HTMLElement,
strengthText: HTMLElement,
say: (msg: string) => void, // the page's announcer, not a local live region
estimate: (pw: string) => 0 | 1 | 2 | 3 | 4,
) {
let lastAllMet = false;
let lastScoreBand = "";
const setRule = (rule: string, state: RuleState) => {
const li = list.querySelector<HTMLElement>(`[data-rule="${rule}"]`)!;
li.dataset.state = state; // CSS adds colour and an icon
li.querySelector(".state")!.textContent =
state === "met" ? "Met: " : state === "not-met" ? "Not met: " : "Not checked: ";
};
input.addEventListener("input", () => {
const pw = input.value;
setRule("length", [...pw].length >= 12 ? "met" : "not-met"); // count graphemes-ish, not UTF-16 units
// The breach check runs on blur (network); mark it unchecked while typing.
setRule("breach", "unchecked");
const score = pw ? estimate(pw) : null;
const band = score === null ? "not rated yet" : ["very weak", "weak", "fair", "strong", "very strong"][score];
strengthText.textContent = band;
// Announce ONLY when the overall status crosses a threshold, never per keystroke.
const allMet = [...pw].length >= 12;
if (allMet !== lastAllMet) { say(allMet ? "Length requirement met." : "Password must be at least 12 characters."); lastAllMet = allMet; }
if (band !== lastScoreBand && (band === "strong" || band === "very strong")) say(`Password strength: ${band}.`);
lastScoreBand = band;
});
}
// Show/hide toggle: a real button whose pressed state is exposed.
export function wireToggle(button: HTMLButtonElement, input: HTMLInputElement) {
button.addEventListener("click", () => {
const show = input.type === "password";
input.type = show ? "text" : "password";
button.setAttribute("aria-pressed", String(show));
button.textContent = show ? "Hide password" : "Show password";
input.focus(); // keep the user's place
});
}
Step-by-step walkthrough
- Show requirements before the user types. A list under the label, referenced by the input’s
aria-describedby, so screen-reader users hear the rules when they reach the field — not only after failing. - Write each rule’s state as text. “Met:”, “Not met:”, “Not checked:” prefixes (visually styled or hidden) carry the state; colour and icons reinforce it, per error styling that does not rely on colour.
- Update the list silently while typing. Users who want the current state read it by navigating the list or re-focusing the field.
- Announce only threshold crossings. “Length requirement met” once, when it flips — via the page announcer from throttling live region announcements, which also waits for typing pauses.
- Present strength as advice in text. “Strength: fair” next to any bar; do not block submission on strength unless it is a stated requirement.
- Provide a show-password toggle. A
buttonwitharia-pressed, returning focus to the input. It helps everyone check what they typed and removes the need for a “confirm password” field. - Use
autocomplete="new-password". Password managers then offer to generate a password, which will meet any sensible requirements.
Why a “confirm password” field is often unnecessary
The confirmation field exists to catch typos in a hidden password. A show-password toggle achieves the same with less effort: users can look at what they typed, once. Password managers fill both fields identically anyway, so the confirmation adds nothing for them, while for people using switch access, voice control or screen magnification it doubles the work of the hardest field on the form. Keep confirmation only where a typo would be expensive to recover from and there is no reset route — and if you keep it, validate the match as described in password confirmation validation pattern.
Failure modes and edge cases
1. Announcing on every keystroke
A live region that reads the whole checklist per character makes the field unusable with a screen reader. Update silently; announce threshold crossings only.
2. Blocking paste
Disabling paste (onpaste="return false") breaks password managers and forces users to type long passwords by hand, pushing them toward weaker ones. Never block paste.
3. Counting characters incorrectly
"🔑".length is 2 in JavaScript. Counting with [...pw].length (code points) is closer to what users see; for full accuracy with combined emoji, use Intl.Segmenter by grapheme. Make the server count the same way.
4. Breach checks leaking the password
Never send the password itself to a third-party service. Range-query APIs based on k-anonymity (sending only a hash prefix) let you check breach lists without revealing the password; better still, perform the check on your server during submit.
5. Strength meters that contradict the rules
A password that meets every requirement but shows “Weak” in red confuses users. Align them: if strength is advisory, word it as advice (“Could be stronger: add more words”), and never style advisory feedback like an error.
Verification checklist
Frequently Asked Questions
Should the strength meter use role="progressbar" or meter?
A <meter> element fits a strength gauge semantically, but its value is announced inconsistently. The most reliable approach is visible text (“Strength: fair”) referenced by the input’s description, with the bar as decoration.
Is zxcvbn too large to load on every page?
It is large because it contains dictionaries. Load it lazily when the password field receives focus, or run the estimate on the server. The checklist and length rule work without it in the meantime.
How do I phrase the breached-password error?
Say what to do without alarming: “This password has appeared in a data breach elsewhere, so it’s easy to guess. Choose a different one.” Avoid implying the user’s own account was breached.