Password strength
StableAn advisory meter that guesses like an attacker, plus the blocklist check. Four grey segments and a word; it never blocks.
estimateStrength cuts a password into the pieces an attacker tries first (the person’s own name and email, common words with l33t swaps undone, repeats, sequences, keyboard runs, years) and brute-forces the rest, like zxcvbn but small enough to ship inline. PasswordStrength runs it as people type and shows the result; StrengthMeter draws a result you already have. Only the length and isBlocked may stop a password (NIST SP 800-63B-4); the meter only helps.
Under a new password
import { useState } from 'react'import { PasswordInput, PasswordStrength } from '@rchursin/product/forms'import { Field } from '@rchursin/product/ui'
/** The person's own name and email, so the meter can spot them in the password */const account = { name: 'Alex Rivera', email: 'alex@example.com' }
/** * The meter sits under the field and is part of its description, so screen readers hear it * with the field, not after every key. It advises; only the length and the blocklist can block. */export default function PasswordStrengthNewPassword() { const [password, setPassword] = useState('') return ( <div className="flex w-full max-w-sm flex-col gap-4"> <p className="text-xs text-fg-3"> Signing up as {account.name}, {account.email} <span className="font-mono text-[10px] tracking-[0.12em] uppercase">(sample)</span> </p> <Field label="New password" hint="At least 15 characters."> {(p) => ( <> <PasswordInput {...p} size="lg" autoComplete="new-password" describedBy={`${p.id}-strength`} value={password} onChange={(e) => setPassword(e.target.value)} /> <PasswordStrength id={`${p.id}-strength`} password={password} inputs={[account.name, account.email]} /> </> )} </Field> </div> )}Usage
import { PasswordStrength, StrengthMeter, estimateStrength, isBlocked } from '@rchursin/product/forms'<Field label="New password" hint="At least 15 characters."> {(p) => ( <> <PasswordInput {...p} autoComplete="new-password" describedBy={`${p.id}-strength`} value={password} onChange={(e) => setPassword(e.target.value)} /> <PasswordStrength id={`${p.id}-strength`} password={password} inputs={[name, email]} /> </> )}</Field>Examples
Every state
StrengthMeter fed by estimateStrength, from nothing typed to Strong.
import { estimateStrength, StrengthMeter } from '@rchursin/product/forms'
const inputs = ['Alex Rivera', 'alex@example.com']const samples = ['', 'cobalt-lamp', 'correct horse battery staple', 'rivera-alex-2024', 'coffee-cookie-team', 'lantern orbit velvet']
/** * StrengthMeter draws a result you already have. Here each one comes from estimateStrength, * for sample passwords from “Alex Rivera”: four grey segments and a word, so shade never * carries the meaning alone. */export default function StrengthMeterStates() { return ( <ul className="flex w-full max-w-sm flex-col gap-5"> {samples.map((password) => ( <li key={password} className="flex flex-col gap-2"> <p className={password ? 'truncate font-mono text-xs text-fg-2' : 'text-xs text-fg-3'}>{password ? `“${password}”` : 'Nothing typed yet'}</p> <StrengthMeter strength={password ? estimateStrength(password, inputs) : null} /> </li> ))} </ul> )}What it knows
The numbers behind the meter, and the one check that decides. A password can be Weak and still accepted, or score 4 and still be too short.
import { useState } from 'react'import { checkNewPassword, estimateStrength, isBlocked } from '@rchursin/product/forms'import { Field, TextInput } from '@rchursin/product/ui'
const inputs = ['Alex Rivera', 'alex@example.com']
/** * What the meter knows. estimateStrength advises (score, guesses, a tip); checkNewPassword * decides, with only two rules: the length and the blocklist (isBlocked). */export default function PasswordStrengthEstimate() { const [password, setPassword] = useState('summerwinter2024') const s = estimateStrength(password, inputs) const verdict = checkNewPassword(password) const rows: [string, string][] = [ ['score', `${s.score} of 4 · ${s.label}`], ['log10', `${s.log10.toFixed(1)} (about 10^${Math.round(s.log10)} guesses)`], ['tip', s.tip ?? 'null'], ['missing', String(s.missing)], ['isBlocked', String(isBlocked(password))],API
PasswordStrength props
| Prop | Type | Default | Description |
|---|---|---|---|
passwordrequired | string | – | What has been typed. Empty shows the “Not set” state with the length advice. |
inputs | string[] | [] | The person’s name, email and other words they would use (zxcvbn’s user_inputs). Parts of 3 or more characters count as the cheapest guesses. |
minLength | number | 15 | Below it the label reads “Too short” and the note counts the characters to go. The empty-state note asks for this many too (“Use 15 or more characters”). |
id | string | – | Give it, and pass the same id to the field’s describedBy, so the meter is read with the field. |
className | string | – | Classes on the wrapper. |
StrengthMeter props
| Prop | Type | Default | Description |
|---|---|---|---|
strengthrequired | Strength | null | – | A result from estimateStrength (or your server). null draws the empty state. |
minLength | number | 15 | The minimum the empty-state note asks for. PasswordStrength passes its own. |
id | string | – | For aria-describedby. |
className | string | – | Classes on the wrapper. |
estimateStrength(password, inputs, minLength) and isBlocked(password)
| Prop | Type | Default | Description |
|---|---|---|---|
passwordrequired | string | – | Normalised to NFC first, and counted in characters as people count them. |
inputs | string[] | [] | The person’s own words, as above. |
minLength | number | 15 | Sets missing and the “Too short” label. |
isBlocked | (password: string) => boolean | – | True for about seventy of the most common leaked passwords (including long ones that pass a 15-character minimum), compared whole, trimmed and case-insensitively, and again with spaces removed (“correct horse battery staple”). |
Strength (the result)
| Prop | Type | Default | Description |
|---|---|---|---|
scorerequired | 0 | 1 | 2 | 3 | 4 | – | zxcvbn-style: guesses below 10^3, 10^6, 10^8, 10^10, or above. |
segmentsrequired | 0 | 1 | 2 | 3 | 4 | – | Filled segments: 0 while too short, otherwise at least 1. |
labelrequired | StrengthLabel | – | The word under the meter: 'Too short' | 'Weak' | 'Fair' | 'Good' | 'Strong'. |
log10required | number | – | Estimated guesses as a power of ten. |
tiprequired | string | null | – | One short suggestion, such as “Leave out your name and email.”; null when there is nothing useful to say. |
missingrequired | number | – | Characters still needed to reach minLength. |
Accessibility
- The segments are aria-hidden. The label is text with a hidden “Password strength: ” in front, so it is read as “Password strength: Fair”.
- There is no live region: with its id in the field’s describedBy, the meter and its tip are read with the field, not after every key.
- Four segments from light to full ink plus a word, so shade is never the only signal. They fade between states only when motion is allowed.
Guidelines
Do
- Pass the person’s name and email as inputs.
- Pair it with checkNewPassword: the meter advises, the check decides.
- Lazy-load zxcvbn or @zxcvbn-ts/core if you need the full dictionaries; the Strength shape stays the same.
Don’t
- Don’t refuse a password because of its score; NIST SP 800-63B-4 allows only a minimum length and a blocklist.
- Don’t add rules for digits, symbols or capitals.
- Don’t put the meter in a live region.
In use
Related
- Password inputA password field with a Show / Hide button and a caps-lock warning, where paste and password managers always work.
- Form toolkituseForm for values, errors and validation timing, plus the checks and GOV.UK-style messages every form in the kit shares.
- Sign-in flowThe whole front door in one component: email first, password or passkey, GitHub or Google, sign-up with a code, and recovery.
- Account settingsThe in-app settings form: profile with a picture and time zone, a password change, passkeys and sessions, with an error summary on save.
Source
The component’s own files, exactly as they run on this site.
/** * Advisory strength meter: four segments from light to dark grey plus a word, so it never * relies on shade alone. It guides; it never blocks (see strength.ts and validation.ts). * * <PasswordInput … describedBy={meterId} /> * <PasswordStrength id={meterId} password={pw} inputs={[name, email]} /> * * Its text is part of the field's description (aria-describedby), so screen readers hear it * with the field instead of after every keystroke. */import { useMemo } from "react"import { cx } from "../../components/cx"import { estimateStrength, type Strength } from "./strength"import { PASSWORD_MIN } from "./validation"
const fills = ["bg-fg/30", "bg-fg/50", "bg-fg/75", "bg-fg"]
export function PasswordStrength({ password, inputs = [],