← All components
Week 2 · 2026-09-30

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";
}