"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface PasswordRequirement {
id: string;
label: string;
test: (value: string) => boolean;
}
export type PasswordScore = 0 | 1 | 2 | 3 | 4;
export interface PasswordStrengthResult {
/** 0 very weak · 1 weak · 2 fair · 3 good · 4 strong. */
score: PasswordScore;
/** Rough entropy estimate in bits, after pattern penalties. */
bits: number;
/** Short, human-readable suggestions (most important first). */
feedback: string[];
}
export interface PasswordStrengthProps extends Omit<React.ComponentPropsWithoutRef<"input">, "type" | "size"> {
/** Visible label, rendered as a real <label htmlFor>. */
label?: React.ReactNode;
/** Helper text below the field (linked via aria-describedby). */
hint?: React.ReactNode;
/** Error message. Any truthy value marks the field invalid; a node is also shown. */
error?: React.ReactNode;
/** Minimum length, used by the default requirements, the heuristic and native validation. */
minLength?: number;
/** Checklist rules. Defaults to length, upper + lower case, a number and a symbol. */
requirements?: PasswordRequirement[];
/** Show the live requirement checklist. */
showRequirements?: boolean;
/** Show the heuristic's top suggestion under the meter. */
showFeedback?: boolean;
/** Show the show/hide toggle. */
allowReveal?: boolean;
/** Labels for scores 0–4. */
strengthLabels?: [string, string, string, string, string];
/** Called when typing changes the score (or empties the field). */
onStrengthChange?: (result: PasswordStrengthResult) => void;
/** Focus ring and toggle colour (any CSS colour). */
accent?: string;
/** Class for the <input>; `className` styles the outer wrapper. */
inputClassName?: string;
}
export const DEFAULT_STRENGTH_LABELS: [string, string, string, string, string] = ["Very weak", "Weak", "Fair", "Good", "Strong"];
/** The default checklist for a given minimum length. */
export function defaultPasswordRequirements(minLength = 8): PasswordRequirement[] {
return [
{ id: "length", label: `At least ${minLength} characters`, test: (v) => [...v].length >= minLength },
{ id: "case", label: "Upper and lower case", test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v) },
{ id: "number", label: "A number", test: (v) => /\d/.test(v) },
{ id: "symbol", label: "A symbol", test: (v) => /[^A-Za-z0-9\s]/.test(v) },
];
}
/* ------------------------------ Heuristic -------------------------------- */
// A deliberately small list: the most common passwords and keyboard walks.
const COMMON = [
"password", "passw0rd", "qwerty", "qwertz", "azerty", "asdf", "zxcv", "letmein", "welcome", "admin", "login",
"iloveyou", "monkey", "dragon", "football", "baseball", "master", "sunshine", "princess", "shadow", "superman",
"trustno1", "abc123", "123456", "111111", "000000", "654321", "123123", "secret", "changeme", "hello",
];
/**
* A transparent strength estimate (no dictionary download, nothing leaves the browser):
* 1. Character pool from the classes used (lower 26, upper 26, digits 10, symbols 33, other 100).
* 2. Effective length = length minus penalties: repeats (aaa) and sequences (abc, 321) count a quarter,
* common passwords / keyboard walks count nothing, a year (19xx/20xx) counts half.
* 3. bits = effectiveLength × log2(pool) → score thresholds 28 / 40 / 56 / 72 bits.
* 4. Caps: shorter than minLength → at most 1; contains a common password → at most 1.
* It is a rough guide for UI feedback, not a security guarantee. Always validate server-side.
*/
export function estimatePasswordStrength(password: string, minLength = 8): PasswordStrengthResult {
const chars = [...password];
const length = chars.length;
if (!length) return { score: 0, bits: 0, feedback: [] };
let pool = 0;
if (/[a-z]/.test(password)) pool += 26;
if (/[A-Z]/.test(password)) pool += 26;
if (/\d/.test(password)) pool += 10;
if (/[!-/:-@[-`{-~ ]/.test(password)) pool += 33;
if (chars.some((c) => (c.codePointAt(0) ?? 0) > 127)) pool += 100;
const feedback: string[] = [];
let penalty = 0;
// Repeats and straight sequences (by char code, either direction).
let repeats = 0;
let sequential = 0;
for (let i = 1; i < length; i++) {
const a = chars[i - 1].toLowerCase().codePointAt(0) ?? 0;
const b = chars[i].toLowerCase().codePointAt(0) ?? 0;
if (a === b) repeats++;
else if (Math.abs(a - b) === 1) sequential++;
}
penalty += (repeats + sequential) * 0.75;
if (repeats >= 2) feedback.push("Avoid repeated characters");
if (sequential >= 3) feedback.push("Avoid sequences like abc or 123");
const lower = password.toLowerCase();
const common = COMMON.filter((w) => lower.includes(w));
if (common.length) {
penalty += Math.max(...common.map((w) => w.length));
feedback.unshift("Avoid common passwords and keyboard patterns");
}
if (/(19|20)\d\d/.test(password)) {
penalty += 2;
feedback.push("Years are easy to guess");
}
const effective = Math.max(0, length - penalty);
const bits = effective * Math.log2(Math.max(pool, 2));
let score: PasswordScore = bits < 28 ? 0 : bits < 40 ? 1 : bits < 56 ? 2 : bits < 72 ? 3 : 4;
if (length < minLength) {
score = Math.min(score, 1) as PasswordScore;
feedback.unshift(`Use at least ${minLength} characters`);
}
if (common.length) score = Math.min(score, 1) as PasswordScore;
if (score < 3 && length >= minLength && pool <= 36) feedback.push("Mix in other character types, or make it longer");
return { score, bits: Math.round(bits), feedback };
}
/* -------------------------------------------------------------------------- */
const LEVEL_COLORS = ["#E11D48", "#F43F5E", "#F59E0B", "#84CC16", "#10B981"];
/**
* A password field with a show/hide toggle, a four-segment strength meter driven by a small,
* documented heuristic, and a live requirement checklist. The strength label is announced
* politely (debounced) and the meter and checklist are linked with aria-describedby.
*/
export const PasswordStrength = React.forwardRef<HTMLInputElement, PasswordStrengthProps>(function PasswordStrength(
{
label = "Password",
hint,
error,
minLength = 8,
requirements,
showRequirements = true,
showFeedback = true,
allowReveal = true,
strengthLabels = DEFAULT_STRENGTH_LABELS,
onStrengthChange,
accent = "#FF7448",
inputClassName = "",
className = "",
style,
id: idProp,
value,
defaultValue,
onChange,
disabled,
autoComplete = "new-password",
...props
},
ref,
) {
const reduceMotion = usePrefersReducedMotion();
const autoId = React.useId();
const id = idProp ?? `pw-${autoId}`;
const meterId = `${id}-strength`;
const listId = `${id}-requirements`;
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
// Uncontrolled inputs are mirrored so the meter can follow them.
const [inner, setInner] = React.useState(() => (defaultValue === undefined ? "" : String(defaultValue)));
const password = value === undefined ? inner : String(value);
const [revealed, setRevealed] = React.useState(false);
const rules = requirements ?? defaultPasswordRequirements(minLength);
const checks = rules.map((r) => ({ ...r, met: r.test(password) }));
const metCount = checks.filter((c) => c.met).length;
const result = estimatePasswordStrength(password, minLength);
const { score } = result;
const empty = password.length === 0;
const strengthText = empty ? "Not entered" : strengthLabels[score];
const color = LEVEL_COLORS[score];
// Debounced announcement so every keystroke isn't read out.
const spoken = empty
? ""
: `Password strength: ${strengthText}.${showRequirements && rules.length ? ` ${metCount} of ${rules.length} requirements met.` : ""}`;
const [announcement, setAnnouncement] = React.useState("");
React.useEffect(() => {
const t = setTimeout(() => setAnnouncement(spoken), 900);
return () => clearTimeout(t);
}, [spoken]);
const invalid = Boolean(error);
const hasErrorText = invalid && error !== true;
const describedBy =
[meterId, showRequirements ? listId : null, hint ? hintId : null, hasErrorText ? errorId : null, props["aria-describedby"]]
.filter(Boolean)
.join(" ") || undefined;
const filled = empty ? 0 : Math.max(1, score); // a typed password always lights at least one segment
return (
<div className={`w-full ${className}`} style={{ ["--pw-accent" as string]: accent, ["--pw-level" as string]: color, ...style }}>
<label htmlFor={id} className="mb-2 block font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</label>
<div
className={`relative flex h-12 items-center rounded-xl border bg-white transition-[border-color,box-shadow] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${
invalid
? "border-rose-500/80 dark:border-rose-400/80"
: "border-zinc-900/15 hover:border-zinc-900/30 focus-within:border-[var(--pw-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--pw-accent)_22%,transparent)] dark:border-white/15 dark:hover:border-white/30"
} ${disabled ? "opacity-50" : ""}`}
>
<input
ref={ref}
id={id}
type={revealed ? "text" : "password"}
autoComplete={autoComplete}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
minLength={minLength}
disabled={disabled}
{...(value === undefined ? { defaultValue } : { value })}
onChange={(event) => {
const next = event.target.value;
if (value === undefined) setInner(next);
onChange?.(event);
// Report score changes caused by typing (an empty field counts as its own state).
const nextResult = estimatePasswordStrength(next, minLength);
if (nextResult.score !== score || (next.length === 0) !== empty) onStrengthChange?.(nextResult);
}}
{...props}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
className={`h-full min-w-0 flex-1 rounded-xl bg-transparent pl-3.5 text-[15px] text-zinc-950 outline-none placeholder:text-zinc-400 disabled:cursor-not-allowed dark:text-zinc-50 dark:placeholder:text-zinc-500 ${
allowReveal ? "pr-1" : "pr-3.5"
} ${revealed ? "" : "tracking-[0.08em] placeholder:tracking-normal"} ${inputClassName}`}
/>
{allowReveal && (
<button
type="button"
aria-label="Show password"
aria-pressed={revealed}
aria-controls={id}
disabled={disabled}
onClick={() => setRevealed((r) => !r)}
className="mr-1.5 grid size-9 shrink-0 place-items-center rounded-lg text-zinc-500 outline-none transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--pw-accent)] disabled:pointer-events-none dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white"
>
<svg aria-hidden="true" viewBox="0 0 20 20" className="size-[18px]" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M1.75 10s3-5.75 8.25-5.75S18.25 10 18.25 10 15.25 15.75 10 15.75 1.75 10 1.75 10Z" />
<circle cx="10" cy="10" r="2.6" />
{/* The slash draws in while the password is visible. */}
<motion.path
d="M3.5 16.5 16.5 3.5"
initial={false}
animate={{ pathLength: revealed ? 1 : 0, opacity: revealed ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.22, ease: "easeOut" }}
/>
</svg>
</button>
)}
</div>
{/* Strength meter: four segments filling left to right, plus a text label (never colour alone). */}
<div className="mt-2.5 flex items-center gap-3">
<div
role="meter"
aria-label="Password strength"
aria-valuemin={0}
aria-valuemax={4}
aria-valuenow={empty ? 0 : score}
aria-valuetext={strengthText}
className="grid flex-1 grid-cols-4 gap-1"
>
{[0, 1, 2, 3].map((i) => (
<span key={i} className="relative h-1.5 overflow-hidden rounded-full bg-zinc-900/[0.08] dark:bg-white/[0.1]">
<span
className="absolute inset-0 origin-left rounded-full bg-[var(--pw-level)] transition-[scale,background-color] duration-300 ease-[cubic-bezier(0.2,0.7,0.2,1)] motion-reduce:transition-none"
style={{ scale: i < filled ? "1 1" : "0 1", transitionDelay: reduceMotion ? "0ms" : `${(i < filled ? i : 3 - i) * 45}ms` }}
/>
</span>
))}
</div>
<span
id={meterId}
className={`min-w-[5.5rem] text-right font-mono text-[10px] uppercase tracking-[0.14em] ${
empty ? "text-zinc-400 dark:text-zinc-500" : "text-zinc-800 dark:text-zinc-200"
}`}
>
<span className="sr-only">Strength: </span>
{empty ? "—" : strengthText}
</span>
</div>
{showFeedback && (
<p className="mt-1.5 min-h-[18px] text-[12.5px] leading-snug text-zinc-500 dark:text-zinc-400">
{!empty && result.feedback[0] ? result.feedback[0] + "." : null}
</p>
)}
{showRequirements && rules.length > 0 && (
<ul id={listId} aria-label="Password requirements" className="mt-2 grid gap-1.5 sm:grid-cols-2">
{checks.map((c) => (
<li key={c.id} className="flex items-center gap-2 text-[13px] leading-snug">
<span
aria-hidden="true"
className={`relative grid size-4 shrink-0 place-items-center rounded-full border transition-colors duration-200 motion-reduce:transition-none ${
c.met
? "border-emerald-500 bg-emerald-500 text-white dark:border-emerald-400 dark:bg-emerald-400 dark:text-zinc-950"
: "border-zinc-900/20 text-transparent dark:border-white/20"
}`}
>
<svg viewBox="0 0 12 12" className="size-2.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path
d="M2.5 6.2 5 8.5l4.5-5"
initial={false}
animate={{ pathLength: c.met ? 1 : 0 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 30 }}
/>
</svg>
</span>
<span className={c.met ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-500 dark:text-zinc-400"}>
{c.label}
<span className="sr-only">{c.met ? " — met" : " — not met"}</span>
</span>
</li>
))}
</ul>
)}
{hint && (
<p id={hintId} className="mt-2 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400">
{hint}
</p>
)}
<div aria-live="polite">
<AnimatePresence initial={false}>
{hasErrorText && (
<motion.p
key="error"
id={errorId}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, height: "auto", y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
transition={{ duration: reduceMotion ? 0 : 0.2, ease: [0.2, 0.7, 0.2, 1] }}
className="overflow-hidden text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400"
>
<span className="block pt-1.5">{error}</span>
</motion.p>
)}
</AnimatePresence>
</div>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</span>
</div>
);
});