Live Password Requirements Checklist

How do you show every password rule up front, tick each one off as the user types, and make sure the checklist, the error message and the browser’s own validity state never disagree? This recipe renders the checklist from a single rule table, updates it on every input event, mirrors the first unmet rule into setCustomValidity() so the Constraint Validation API stays authoritative, and wires the list to the field with aria-describedby so screen reader users get the same information sighted users do — without a live region firing on every keystroke.

When to Use a Live Requirements Checklist

A checklist is the right tool when a field has several independent, binary rules that users cannot guess. Passwords are the canonical case, but usernames and some identifier fields share the shape. Reach for it when:

  • The rules are hard constraints. Every item on the list must block submission if unmet. Advisory signals such as “guessability” belong in the password strength meter instead.
  • There are two to five rules. One rule is just hint text; more than five suggests the policy itself needs simplifying.
  • Rules can be checked synchronously. A rule that needs the network, like breach screening, should appear as a pending item that resolves after a debounce, not flicker on every keystroke.

Compared with a single error message that appears after submit, the checklist moves the information to before the mistake. Compared with inline errors on blur, it gives continuous feedback while typing, which suits passwords specifically because users cannot see what they typed.

Checklist versus single error message for password rules Two columns comparing a live requirement checklist with a single post-submit error message for communicating password rules. Live checklist • all rules visible before typing • each rule ticks off as it is met ✓ users rarely fail on submit ✓ rule text doubles as error text ✗ needs care to avoid noisy announcements Single message after submit • rules hidden until a failure • message names one rule at a time ✗ users fail, fix, fail again ✗ invisible input makes retries painful ✓ simplest to implement
The checklist front-loads the rules and resolves them live; the single message only explains the rule after it has been broken.

Minimal Working Checklist Implementation

The rule table is the single source of truth. The checklist markup, the custom validity message and the unit tests all derive from it, so adding a rule is a one-line change.

<label for="pw">Password</label>
<input id="pw" name="password" type="password" required minlength="12"
       autocomplete="new-password" aria-describedby="pw-reqs-heading pw-reqs pw-err">
<p id="pw-reqs-heading" class="reqs-heading">Your password needs:</p>
<ul id="pw-reqs" class="reqs"></ul>
<p id="pw-err" class="field-error" hidden></p>
interface Requirement {
  id: string;
  text: string;
  test: (value: string) => boolean;
}

const REQUIREMENTS: readonly Requirement[] = [
  { id: "len", text: "at least 12 characters", test: (v) => [...v].length >= 12 },
  { id: "space", text: "no spaces at the start or end", test: (v) => v === v.trim() },
  { id: "repeat", text: "no character repeated more than 3 times in a row", test: (v) => !/(.)\1{3,}/u.test(v) },
];

const field = document.querySelector<HTMLInputElement>("#pw")!;
const list = document.querySelector<HTMLUListElement>("#pw-reqs")!;
const error = document.querySelector<HTMLElement>("#pw-err")!;

// Build the list once; afterwards we only flip attributes and text.
const items = new Map<string, HTMLLIElement>();
for (const req of REQUIREMENTS) {
  const li = document.createElement("li");
  li.id = `req-${req.id}`;
  li.dataset.state = "pending";
  li.innerHTML = `<span class="req-status"></span> <span class="req-text"></span>`;
  li.querySelector(".req-text")!.textContent = req.text;
  list.append(li);
  items.set(req.id, li);
}

function sync(): boolean {
  const value = field.value;
  let firstUnmet: Requirement | undefined;
  for (const req of REQUIREMENTS) {
    const met = value.length > 0 && req.test(value);
    const li = items.get(req.id)!;
    li.dataset.state = value.length === 0 ? "pending" : met ? "met" : "unmet";
    // State in words, visually hidden or visible — never colour alone.
    li.querySelector(".req-status")!.textContent = value.length === 0 ? "" : met ? "Done:" : "Still needed:";
    if (!met && !firstUnmet) firstUnmet = req;
  }
  // Mirror into the native validity model so reportValidity() tells the same story.
  field.setCustomValidity(firstUnmet && value.length > 0 ? `Password needs ${firstUnmet.text}.` : "");
  return !firstUnmet;
}

field.addEventListener("input", () => {
  sync();
  // Once an error is showing, clear it the moment the rules are all met.
  if (!error.hidden && field.validity.valid) {
    error.hidden = true;
    field.removeAttribute("aria-invalid");
  }
});

field.form!.addEventListener("submit", (event) => {
  sync();
  if (!field.form!.checkValidity()) {
    event.preventDefault();
    error.textContent = field.validationMessage; // names the first unmet rule
    error.hidden = false;
    field.setAttribute("aria-invalid", "true");
    field.form!.reportValidity();
  }
});

The pending state matters: before the user types anything, rules should look neutral rather than failed. Painting three red crosses on an empty field is the checklist equivalent of showing an error before the user has had a chance — the problem the inline validation timing guide describes as “punishing early”.

Requirement item states Each checklist item starts pending, becomes met or unmet as the user types, and returns to pending if the field is cleared. pending unmet met typing, rule fails rule now passes edit breaks rule typing, rule passes field cleared
Items only turn "unmet" once there is a value to judge; clearing the field returns every item to the neutral pending state.

Checklist Option Reference

Option Type Default Purpose
REQUIREMENTS[].id string Stable key for tests and element ids
REQUIREMENTS[].text string Shown in the list and reused in the error message
REQUIREMENTS[].test (v: string) => boolean Pure predicate; must be synchronous
data-state "pending" | "met" | "unmet" pending Hook for CSS icons and colours
Status prefix text string Done: / Still needed: Carries state in words for WCAG 1.4.1
Error container element id pw-err Receives validationMessage on failed submit
aria-describedby order id list heading, list, error Controls the reading order after the label

Keep the status prefix as real text even if you also draw icons. A visually hidden span works if the design wants icons only, but the words must exist in the accessibility tree; a CSS ::before icon does not reliably announce.

.reqs li[data-state="met"]   { color: var(--ok-text); }
.reqs li[data-state="unmet"] { color: var(--warn-text); }
.reqs li[data-state="met"]   .req-status::before { content: "✓ "; }
.reqs li[data-state="unmet"] .req-status::before { content: "• "; }

Verification Steps

Checklist mid-typing A password field partway through typing, with two requirements met and one still needed, and the ARIA wiring annotated. Choose a password Password •••••••••• 1 ✗ at least 12 characters ✓ no spaces at the start or end ✓ no character repeated more than 3 times in a row Continue 1 aria-describedby lists the heading, the list and the error, in that order 2 Each item says "Done:" or "Still needed:" in text as well as colour 3 No aria-live on the list, so typing is never interrupted
While typing, items flip silently; the combined description is read when the field is focused and the error only after a failed submit.
import { test, expect } from "@playwright/test";

test("checklist and validity stay in sync", async ({ page }) => {
  await page.goto("/signup");
  const pw = page.getByLabel("Password");
  await pw.pressSequentially("short");
  await expect(page.locator("#req-len")).toHaveAttribute("data-state", "unmet");
  await page.getByRole("button", { name: "Continue" }).click();
  await expect(pw).toHaveAttribute("aria-invalid", "true");
  await expect(page.locator("#pw-err")).toHaveText("Password needs at least 12 characters.");
  await pw.pressSequentially(" but longer now");
  await expect(page.locator("#req-len")).toHaveAttribute("data-state", "met");
  await expect(page.locator("#pw-err")).toBeHidden();
});

Edge Cases and Failure Modes

Checklist and error disagree. If the checklist is built from one array and the error from a hand-written if chain, they drift. Always derive both from the same REQUIREMENTS table, and assert in a unit test that every rule’s text appears in its own error message.

Password managers fill the field without input. Some managers set the value and dispatch only change. Listen for both, or the checklist stays pending next to a perfectly valid generated password.

for (const type of ["input", "change"] as const) field.addEventListener(type, sync);

Asynchronous rules in a synchronous list. A breach check cannot run per keystroke. Render it as a separate item that shows “Checking…” after a debounce and resolves later, following the pattern in checking passwords against breached lists; keep it out of sync() so the synchronous items remain instant.

Hidden-field reveal. When the user toggles visibility with the show/hide password toggle, nothing about the rules changes; do not rebuild the list or re-run sync() on the toggle click.

Rendering the Checklist Server-Side for Progressive Enhancement

The checklist is built by script above, which means users without JavaScript — or users on a slow connection before the bundle arrives — see no rules at all. Render the list in the HTML instead, in its neutral pending state, and let the script take over the existing items rather than creating them. The rules then appear instantly, survive a failed script load, and give the server-rendered error page something to reference when a submission comes back rejected.

<ul id="pw-reqs" class="reqs">
  <li id="req-len" data-state="pending"><span class="req-status"></span> <span class="req-text">at least 12 characters</span></li>
  <li id="req-space" data-state="pending"><span class="req-status"></span> <span class="req-text">no spaces at the start or end</span></li>
  <li id="req-repeat" data-state="pending"><span class="req-status"></span> <span class="req-text">no character repeated more than 3 times in a row</span></li>
</ul>
// Adopt server-rendered items instead of creating new ones.
for (const req of REQUIREMENTS) {
  const li = document.getElementById(`req-${req.id}`) as HTMLLIElement | null;
  if (!li) throw new Error(`Missing checklist item for rule "${req.id}"`);
  items.set(req.id, li);
}

The thrown error is deliberate: if a developer adds a rule to the table but forgets the markup, the page fails loudly in development rather than silently omitting a requirement in production. When the server rejects a submission, it can re-render the same list with data-state="unmet" on the failing items, so the user returns to exactly the view they would have seen with scripting enabled. That parity between the scripted and unscripted paths is the core idea of progressive enhancement without JavaScript.

Writing Requirement Text That Works as Both Hint and Error

Each rule’s text is read in two contexts: as a neutral hint before typing (“Your password needs: at least 12 characters”) and inside the error sentence after a failed submit (“Password needs at least 12 characters.”). Writing it as a lowercase noun phrase that completes “needs…” makes both readings grammatical, which is why the table stores “at least 12 characters” rather than “Must be 12+ chars”. Avoid jargon such as “alphanumeric” or “special character” — say what counts (“a number or symbol, like 7 or #”) — and never phrase a rule negatively when a positive phrasing exists. The same guidance applies to every other field on the form; the clear inline error message copy guide covers the broader rules.

Frequently Asked Questions

Should the password checklist use aria-live to announce each rule as it is met?

No. Rule changes happen on nearly every keystroke, and live announcements would interrupt the character echo. Reference the list from aria-describedby so it is read on focus, and let the error message carry the announcement on a failed submit.

How do I keep the checklist and the browser validation message consistent?

Derive both from one rule table. After evaluating the rules, call setCustomValidity() with the text of the first unmet rule, and render the error container from validationMessage. Then the checklist, the error and reportValidity() all say the same thing.

Should unmet rules be shown in red before the user types?

No. Show them in a neutral pending state until there is a value to judge. Red crosses on an empty field read as errors the user has not made yet.

What if a password manager fills the field?

Some managers only dispatch a change event or none at all. Listen for both input and change, and re-run the sync function on submit so the verdict is always based on the current value.

← Back to Password Validation Patterns