Password Strength Meter
Estimates how long a password would take to crack, spots common words and patterns, and gives one specific tip to make it stronger.
Enter a password
10 billion guesses per second on a GPU rig
Try a passphrase: four random words are easy to remember and hard to crack.
- Missing: At least 12 characters
- Missing: Upper and lowercase letters
- Missing: A number
- Missing: A symbol, like ! # or $
- Missing: No common words or patterns
Source
Demo.tsx
import PasswordMeter from "./PasswordMeter";
export default function Demo() {
return <PasswordMeter />;
}
PasswordMeter.module.css
.card {
--surface: #fcfcfb;
--ink: #0b0b0b;
--ink-2: #52514e;
--muted: #898781;
--border: rgba(11, 11, 11, 0.1);
--track: #e1e0d9;
--field: #f4f3f0;
--critical: #d03b3b;
--serious: #ec835a;
--warning: #fab219;
--good: #0ca30c;
--tone: var(--track);
width: 100%;
max-width: 360px;
padding: 22px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 32px -12px rgba(0, 0, 0, 0.18);
color: var(--ink);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
@media (prefers-color-scheme: dark) {
.card {
--surface: #1a1a19;
--ink: #ffffff;
--ink-2: #c3c2b7;
--border: rgba(255, 255, 255, 0.1);
--track: #2c2c2a;
--field: #232321;
box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6);
}
}
.card[data-tone="critical"] { --tone: var(--critical); }
.card[data-tone="serious"] { --tone: var(--serious); }
.card[data-tone="warning"] { --tone: var(--warning); }
.card[data-tone="good"] { --tone: var(--good); }
.title { display: block; margin-bottom: 10px; font-size: 14px; font-weight: 600; }
/* Input */
.inputWrap { position: relative; }
.input {
width: 100%;
height: 48px;
padding: 0 48px 0 14px;
border: 1px solid var(--border);
border-radius: 12px;
outline: none;
background: var(--field);
color: var(--ink);
font: inherit;
font-size: 16px;
letter-spacing: 0.02em;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input::placeholder { color: var(--muted); letter-spacing: 0; }
.input:focus { border-color: var(--ink-2); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ink) 10%, transparent); }
.eye {
position: absolute;
top: 50%;
right: 6px;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: 0;
border-radius: 8px;
background: none;
color: var(--ink-2);
cursor: pointer;
transform: translateY(-50%);
}
.eye:hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
.eye:focus-visible { outline: 2px solid var(--ink-2); outline-offset: 1px; }
.eye svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* Meter */
.meter { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin: 14px 0 10px; }
.segment { height: 6px; border-radius: 99px; background: var(--track); transition: background 0.25s ease; }
.segment[data-on="true"] { background: var(--tone); }
.verdict { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; }
.level { display: inline-flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 650; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--tone); }
.time { color: var(--ink-2); font-size: 13px; font-variant-numeric: tabular-nums; }
/* Attack scenario */
.scenarios {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
margin-top: 14px;
padding: 3px;
border-radius: 10px;
background: var(--field);
}
.scenario {
padding: 6px 8px;
border: 0;
border-radius: 8px;
background: none;
color: var(--ink-2);
font: inherit;
font-size: 12px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.scenario[aria-checked="true"] {
background: var(--surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
color: var(--ink);
font-weight: 600;
}
.scenario:focus-visible { outline: 2px solid var(--ink-2); outline-offset: 1px; }
.note { margin: 6px 0 0; color: var(--muted); font-size: 11px; text-align: center; }
/* Tip + checklist */
.tip {
margin: 16px 0 12px;
padding: 10px 12px;
border-left: 3px solid var(--tone);
border-radius: 4px 10px 10px 4px;
background: var(--field);
font-size: 13px;
line-height: 1.45;
transition: border-color 0.25s ease;
}
.checklist { display: flex; flex-direction: column; gap: 7px; margin: 0; padding: 0; list-style: none; font-size: 13px; }
.checklist li { display: flex; align-items: center; gap: 10px; color: var(--ink-2); }
.checklist li[data-ok="true"] { color: var(--ink); }
.check {
display: grid;
flex: none;
place-items: center;
width: 18px;
height: 18px;
border: 1.5px solid var(--track);
border-radius: 50%;
font-size: 11px;
font-weight: 700;
transition: background 0.2s ease, border-color 0.2s ease;
}
.checklist li[data-ok="true"] .check { border-color: var(--good); background: var(--good); color: #fff; }
.srOnly {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* Footer */
.footer { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 18px; }
.generate {
width: 100%;
height: 40px;
border: 0;
border-radius: 10px;
background: var(--ink);
color: var(--surface);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s ease, opacity 0.15s ease;
}
.generate:hover { opacity: 0.88; }
.generate:active { transform: scale(0.98); }
.generate:focus-visible { outline: 2px solid var(--ink-2); outline-offset: 2px; }
.privacy { color: var(--muted); font-size: 11px; }
PasswordMeter.tsx
"use client";
import { useId, useState } from "react";
import { crackSeconds, estimate, formatDuration, type Estimate, type Finding } from "./strength";
import styles from "./PasswordMeter.module.css";
const LEVELS = [
{ label: "Very weak", tone: "critical" },
{ label: "Weak", tone: "serious" },
{ label: "Fair", tone: "warning" },
{ label: "Strong", tone: "good" },
{ label: "Very strong", tone: "good" },
] as const;
const SCENARIOS = [
{ key: "offline", label: "Leaked database", rate: 1e10, note: "10 billion guesses per second on a GPU rig" },
{ key: "online", label: "Login page", rate: 10, note: "10 guesses per second, rate-limited" },
] as const;
const FINDING_TEXT: Record<Finding["kind"], (text: string) => string> = {
common: (t) => `Avoid “${t}”: it's on every attacker's list`,
sequence: (t) => `Avoid sequences like “${t}”`,
keyboard: (t) => `Avoid keyboard patterns like “${t}”`,
repeat: (t) => `Avoid repeats like “${t}”`,
year: (t) => `“${t}” looks like a year, which is easy to guess`,
};
const GENERATOR_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*-_+=?";
function generatePassword(length = 16) {
const random = new Uint32Array(length);
crypto.getRandomValues(random);
return Array.from(random, (n) => GENERATOR_CHARS[n % GENERATOR_CHARS.length]).join("");
}
function topTip(password: string, result: Estimate) {
if (!password) return "Try a passphrase: four random words are easy to remember and hard to crack.";
const common = result.findings.find((f) => f.kind === "common");
if (common) return `Drop “${common.text}”. Adding numbers to a common word barely helps.`;
if (password.length < 12) {
const n = 12 - password.length;
return `Add ${n} more character${n === 1 ? "" : "s"}. Length adds the most strength.`;
}
if (result.findings.length > 0) return `${FINDING_TEXT[result.findings[0].kind](result.findings[0].text)}.`;
if (result.score >= 3) return "Nice. Save it in a password manager so you don't have to remember it.";
return "Make it longer: every extra character multiplies the time to crack it.";
}
export default function PasswordMeter() {
const [password, setPassword] = useState("");
const [visible, setVisible] = useState(false);
const [scenario, setScenario] = useState<(typeof SCENARIOS)[number]["key"]>("offline");
const inputId = useId();
const result = estimate(password);
const empty = password.length === 0;
const level = LEVELS[result.score];
const rate = SCENARIOS.find((s) => s.key === scenario)!;
const time = formatDuration(crackSeconds(result.bits, rate.rate));
const timeLabel =
time === "instantly" ? "Cracked instantly" : time.startsWith("longer") ? `Would take ${time}` : `Cracked in ~${time}`;
const filled = empty ? 0 : Math.max(1, result.score);
const pattern = result.findings[0];
const checklist = [
{ ok: result.checks.length, text: "At least 12 characters" },
{ ok: result.checks.mixedCase, text: "Upper and lowercase letters" },
{ ok: result.checks.digit, text: "A number" },
{ ok: result.checks.symbol, text: "A symbol, like ! # or $" },
{
ok: !empty && !pattern,
text: pattern ? FINDING_TEXT[pattern.kind](pattern.text) : "No common words or patterns",
},
];
return (
<div className={styles.card} data-tone={empty ? "none" : level.tone}>
<label htmlFor={inputId} className={styles.title}>
Password strength
</label>
<div className={styles.inputWrap}>
<input
id={inputId}
className={styles.input}
type={visible ? "text" : "password"}
value={password}
placeholder="Type a password"
autoComplete="new-password"
spellCheck={false}
autoCapitalize="off"
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
className={styles.eye}
aria-label={visible ? "Hide password" : "Show password"}
aria-pressed={visible}
onClick={() => setVisible((v) => !v)}
>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
{!visible && <path d="M4 20 20 4" />}
</svg>
</button>
</div>
<div className={styles.meter} aria-hidden="true">
{[1, 2, 3, 4].map((segment) => (
<span key={segment} className={styles.segment} data-on={segment <= filled} />
))}
</div>
<div className={styles.verdict} aria-live="polite">
<span className={styles.level}>
{!empty && <span className={styles.dot} />}
{empty ? "Enter a password" : level.label}
</span>
<span className={styles.time}>{empty ? "" : timeLabel}</span>
</div>
<div className={styles.scenarios} role="radiogroup" aria-label="Attack scenario">
{SCENARIOS.map((s) => (
<button
key={s.key}
type="button"
role="radio"
aria-checked={s.key === scenario}
className={styles.scenario}
onClick={() => setScenario(s.key)}
>
{s.label}
</button>
))}
</div>
<p className={styles.note}>{rate.note}</p>
<p className={styles.tip}>{topTip(password, result)}</p>
<ul className={styles.checklist}>
{checklist.map((item) => (
<li key={item.text} data-ok={item.ok}>
<span className={styles.check} aria-hidden="true">
{item.ok ? "✓" : ""}
</span>
<span className={styles.srOnly}>{item.ok ? "Done: " : "Missing: "}</span>
{item.text}
</li>
))}
</ul>
<div className={styles.footer}>
<button
type="button"
className={styles.generate}
onClick={() => {
setPassword(generatePassword());
setVisible(true);
}}
>
Generate strong password
</button>
<span className={styles.privacy}>Checked on your device. Nothing is sent.</span>
</div>
</div>
);
}
strength.ts
// A small, dependency-free strength estimator. It starts from brute-force entropy
// (length × log2 of the character pool) and discounts characters that sit inside
// predictable patterns: common passwords, sequences, keyboard rows, repeats and years.
export type Finding = { kind: "common" | "sequence" | "keyboard" | "repeat" | "year"; text: string };
export type Estimate = {
bits: number;
/** 0 very weak … 4 very strong */
score: 0 | 1 | 2 | 3 | 4;
findings: Finding[];
checks: { length: boolean; mixedCase: boolean; digit: boolean; symbol: boolean };
};
const COMMON = [
"password", "123456", "12345678", "qwerty", "abc123", "111111", "letmein", "welcome", "monkey",
"dragon", "iloveyou", "admin", "login", "master", "sunshine", "princess", "football", "baseball",
"shadow", "superman", "trustno1", "hello", "freedom", "whatever", "starwars", "michael", "charlie",
"jessica", "qazwsx", "secret", "access", "computer", "internet", "love", "bangladesh", "dhaka",
];
const KEYBOARD_ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm"];
const LEET: Record<string, string> = { "0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t", "@": "a", "$": "s", "!": "i" };
const SYMBOL = /[!-/:-@[-`{-~]/;
const isAlnum = (c: string) => /[a-z0-9]/.test(c);
/** Half-open [start, end) ranges where `step` holds between neighbours for at least `min` chars. */
function runs(s: string, step: (a: string, b: string) => boolean, min: number) {
const found: [number, number][] = [];
let start = 0;
for (let i = 1; i <= s.length; i++) {
if (i < s.length && step(s[i - 1], s[i])) continue;
if (i - start >= min) found.push([start, i]);
start = i;
}
return found;
}
function keyboardRuns(s: string) {
const found: [number, number][] = [];
for (let i = 0; i < s.length; i++) {
for (let j = s.length; j >= i + 4; j--) {
const chunk = s.slice(i, j);
const reversed = [...chunk].reverse().join("");
if (KEYBOARD_ROWS.some((row) => row.includes(chunk) || row.includes(reversed))) {
found.push([i, j]);
i = j - 1;
break;
}
}
}
return found;
}
export function estimate(password: string): Estimate {
const checks = {
length: password.length >= 12,
mixedCase: /[a-z]/.test(password) && /[A-Z]/.test(password),
digit: /\d/.test(password),
symbol: SYMBOL.test(password) || /\s/.test(password),
};
let pool = 0;
if (/[a-z]/.test(password)) pool += 26;
if (/[A-Z]/.test(password)) pool += 26;
if (/\d/.test(password)) pool += 10;
if (SYMBOL.test(password)) pool += 33;
if (/\s/.test(password)) pool += 1;
if (/[^\x00-\x7F]/.test(password)) pool += 100;
const full = pool > 1 ? Math.log2(pool) : 0;
const cost = Array<number>(password.length).fill(full);
const findings: Finding[] = [];
const lower = password.toLowerCase();
const plain = [...lower].map((c) => LEET[c] ?? c).join("");
function mark(kind: Finding["kind"], [start, end]: [number, number], perChar: (offset: number) => number) {
for (let i = start; i < end; i++) cost[i] = Math.min(cost[i], perChar(i - start));
if (!findings.some((f) => f.kind === kind)) findings.push({ kind, text: password.slice(start, end) });
}
for (const word of COMMON) {
for (const s of [lower, plain]) {
const at = s.indexOf(word);
// The whole word is worth about as much as picking it from the list.
if (at >= 0) mark("common", [at, at + word.length], () => 6 / word.length);
}
}
for (const m of lower.matchAll(/(?:19|20)\d\d/g)) mark("year", [m.index, m.index + 4], () => 7.6 / 4);
const firstFull = (offset: number) => (offset === 0 ? full : 1);
for (const r of runs(lower, (a, b) => a === b, 3)) mark("repeat", r, firstFull);
for (const dir of [1, -1]) {
const step = (a: string, b: string) => isAlnum(a) && isAlnum(b) && b.charCodeAt(0) - a.charCodeAt(0) === dir;
for (const r of runs(lower, step, 3)) mark("sequence", r, firstFull);
}
for (const r of keyboardRuns(lower)) mark("keyboard", r, firstFull);
const bits = cost.reduce((sum, c) => sum + c, 0);
const score = bits < 25 ? 0 : bits < 40 ? 1 : bits < 60 ? 2 : bits < 80 ? 3 : 4;
return { bits, score, findings, checks };
}
/** Average time to guess: half the search space at the given guess rate. */
export function crackSeconds(bits: number, guessesPerSecond: number) {
return 2 ** bits / 2 / guessesPerSecond;
}
const YEAR = 31_557_600;
const STEPS: [number, string, string][] = [
[YEAR * 100, "century", "centuries"],
[YEAR, "year", "years"],
[YEAR / 12, "month", "months"],
[86_400, "day", "days"],
[3_600, "hour", "hours"],
[60, "minute", "minutes"],
[1, "second", "seconds"],
];
const compact = new Intl.NumberFormat("en", { notation: "compact", compactDisplay: "long", maximumFractionDigits: 1 });
export function formatDuration(seconds: number) {
if (seconds < 1) return "instantly";
const years = seconds / YEAR;
if (years > 13.8e9) return "longer than the universe has existed";
if (years >= 10_000) return `${compact.format(years)} years`;
for (const [size, one, many] of STEPS) {
if (seconds >= size) {
const n = Math.round(seconds / size);
return `${n} ${n === 1 ? one : many}`;
}
}
return "instantly";
}