Password Strength Form
Original · freepassword field with a strength meter
byWeb InnoventixReact + Tailwind
formpasswordstrengthforms
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/form-password-strength.jsonform-password-strength.tsx
"use client";
import {
useCallback,
useId,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
type KeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type Requirement = {
id: string;
label: string;
test: (value: string) => boolean;
};
const REQUIREMENTS: Requirement[] = [
{ id: "length", label: "At least 12 characters", test: (v) => v.length >= 12 },
{ id: "lower", label: "A lowercase letter (a–z)", test: (v) => /[a-z]/.test(v) },
{ id: "upper", label: "An uppercase letter (A–Z)", test: (v) => /[A-Z]/.test(v) },
{ id: "number", label: "A number (0–9)", test: (v) => /\d/.test(v) },
{ id: "symbol", label: "A symbol (!@#$%…)", test: (v) => /[^A-Za-z0-9]/.test(v) },
];
type Tier = {
label: string;
bar: string;
text: string;
ring: string;
};
// Index 0 = nothing entered / too weak to score.
const TIERS: Tier[] = [
{ label: "No password yet", bar: "", text: "text-slate-400 dark:text-slate-500", ring: "" },
{ label: "Weak", bar: "bg-rose-500", text: "text-rose-600 dark:text-rose-400", ring: "ring-rose-500/40" },
{ label: "Fair", bar: "bg-amber-500", text: "text-amber-600 dark:text-amber-400", ring: "ring-amber-500/40" },
{ label: "Good", bar: "bg-sky-500", text: "text-sky-600 dark:text-sky-400", ring: "ring-sky-500/40" },
{ label: "Strong", bar: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400", ring: "ring-emerald-500/40" },
];
const COMMON = new Set<string>([
"password",
"password1",
"passw0rd",
"123456",
"12345678",
"123456789",
"qwerty",
"qwerty123",
"abc123",
"111111",
"000000",
"letmein",
"welcome",
"admin",
"iloveyou",
"monkey",
"dragon",
"football",
"sunshine",
]);
function looksPredictable(value: string): boolean {
const lower = value.toLowerCase();
if (COMMON.has(lower)) return true;
if (/(.)\1{3,}/.test(value)) return true; // 4+ repeated characters
if (/(?:0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef|qwer|asdf)/i.test(value)) return true;
return false;
}
function randomInt(max: number): number {
if (typeof window !== "undefined" && window.crypto?.getRandomValues) {
const arr = new Uint32Array(1);
window.crypto.getRandomValues(arr);
return (arr[0] ?? 0) % max;
}
return Math.floor(Math.random() * max);
}
function generatePassword(): string {
// Ambiguous glyphs (0/O, 1/l/I) are omitted so the result is easy to read aloud.
const sets = [
"abcdefghijkmnpqrstuvwxyz",
"ABCDEFGHJKLMNPQRSTUVWXYZ",
"23456789",
"!@#$%^&*()-_=+",
];
const all = sets.join("");
const target = 18;
const chars: string[] = [];
for (const set of sets) chars.push(set[randomInt(set.length)] ?? "");
while (chars.length < target) chars.push(all[randomInt(all.length)] ?? "");
for (let i = chars.length - 1; i > 0; i -= 1) {
const j = randomInt(i + 1);
const tmp = chars[i]!;
chars[i] = chars[j]!;
chars[j] = tmp;
}
return chars.join("");
}
function EyeIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
<path strokeLinecap="round" strokeLinejoin="round" d="M10.6 6.2A9.7 9.7 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.6 3.2M6.3 7.9A17 17 0 0 0 2.5 12S6 18 12 18a9.4 9.4 0 0 0 3.3-.6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9.9 10.1a3 3 0 0 0 4.2 4.2" />
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={2.4} aria-hidden="true" className="h-3.5 w-3.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 10.5l3.2 3.2 7.8-8" />
</svg>
);
}
function DotIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={2.4} aria-hidden="true" className="h-3.5 w-3.5">
<path strokeLinecap="round" d="M6 10h8" />
</svg>
);
}
function ShieldIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3l7 2.5v5.2c0 4.5-3 8.1-7 9.3-4-1.2-7-4.8-7-9.3V5.5L12 3Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.8 12.2l2.1 2.1 4.3-4.5" />
</svg>
);
}
function RefreshIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden="true" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.5 8A6 6 0 0 0 5 6.2M4.5 12A6 6 0 0 0 15 13.8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.5 3.5V8h-4.5M4.5 16.5V12h4.5" />
</svg>
);
}
function CopyIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden="true" className="h-4 w-4">
<rect x="7" y="7" width="9" height="9" rx="2" strokeLinejoin="round" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4 13V5a1 1 0 0 1 1-1h8" />
</svg>
);
}
export default function FormPasswordStrength() {
const uid = useId();
const reduce = useReducedMotion();
const pwId = `${uid}-pw`;
const confirmId = `${uid}-confirm`;
const meterId = `${uid}-meter`;
const reqId = `${uid}-req`;
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [revealPw, setRevealPw] = useState(false);
const [revealConfirm, setRevealConfirm] = useState(false);
const [capsLock, setCapsLock] = useState(false);
const [copied, setCopied] = useState(false);
const [submitted, setSubmitted] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const met = useMemo(
() => REQUIREMENTS.map((r) => r.test(password)),
[password],
);
const metCount = met.filter(Boolean).length;
const level = useMemo<number>(() => {
if (password.length === 0) return 0;
if (looksPredictable(password)) return 1;
if (password.length < 8) return 1;
if (metCount <= 2) return 1;
if (metCount === 3) return 2;
if (metCount === 4) return 3;
return 4;
}, [password, metCount]);
const tier = TIERS[level] ?? TIERS[0]!;
const predictable = password.length > 0 && looksPredictable(password);
const mismatch = confirm.length > 0 && confirm !== password;
const matched = confirm.length > 0 && confirm === password;
const canSubmit = level === 4 && matched;
const handlePwCaps = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
setCapsLock(e.getModifierState("CapsLock"));
}, []);
const handleGenerate = useCallback(() => {
const next = generatePassword();
setPassword(next);
setConfirm("");
setRevealPw(true);
setSubmitted(false);
}, []);
const handleCopy = useCallback(() => {
if (!password) return;
const done = () => {
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1800);
};
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(password).then(done).catch(() => undefined);
}
}, [password]);
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!canSubmit) return;
setSubmitted(true);
},
[canSubmit],
);
const handleReset = useCallback(() => {
setPassword("");
setConfirm("");
setRevealPw(false);
setRevealConfirm(false);
setCapsLock(false);
setSubmitted(false);
}, []);
const meterStatus =
password.length === 0
? "Password strength: not set"
: `Password strength: ${tier.label}`;
const inputBase =
"block w-full rounded-xl border bg-white px-4 py-3 pr-12 text-[15px] text-slate-900 shadow-sm outline-none transition placeholder:text-slate-400 focus-visible:ring-4 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500";
const iconBtn =
"absolute right-2 top-1/2 grid h-9 w-9 -translate-y-1/2 place-items-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100";
const pillBtn =
"inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 shadow-sm transition hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white dark:focus-visible:ring-offset-slate-900";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 px-4 py-16 sm:py-24 dark:from-slate-950 dark:to-slate-900">
<style>{`
@keyframes fps-pop {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.18); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fps-glow {
0%, 100% { opacity: 0.4; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
@keyframes fps-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
@keyframes fps-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.fps-pop { animation: fps-pop 0.28s ease-out both; }
.fps-glow { animation: fps-glow 1.8s ease-in-out infinite; }
.fps-shake { animation: fps-shake 0.4s ease-in-out both; }
.fps-rise { animation: fps-rise 0.4s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.fps-pop, .fps-glow, .fps-shake, .fps-rise { animation: none !important; }
}
`}</style>
{/* soft decorative aura */}
<div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-0 -z-0 h-72 w-[36rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15"
/>
<div className="relative z-10 mx-auto w-full max-w-md">
<div className="rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-900/5 backdrop-blur sm:p-8 dark:border-slate-800 dark:bg-slate-900/80 dark:shadow-black/30">
<header className="mb-6 flex items-start gap-3">
<span className="mt-0.5 grid h-10 w-10 flex-none place-items-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/30">
<ShieldIcon />
</span>
<div>
<h2 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white">
Set your password
</h2>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Pick something you don’t use anywhere else. We’ll never ask for it by email or phone.
</p>
</div>
</header>
{submitted ? (
<div className="fps-rise rounded-2xl border border-emerald-200 bg-emerald-50 p-6 text-center dark:border-emerald-900/60 dark:bg-emerald-950/40">
<span className="mx-auto grid h-12 w-12 place-items-center rounded-full bg-emerald-600 text-white">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} aria-hidden="true" className="h-6 w-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12.5l4 4 10-10.5" />
</svg>
</span>
<h3 className="mt-4 text-lg font-semibold text-emerald-900 dark:text-emerald-200">
Password saved
</h3>
<p className="mt-1 text-sm text-emerald-700 dark:text-emerald-300/80">
Your new password clears our security bar. You can sign in with it right away.
</p>
<button
type="button"
onClick={handleReset}
className="mt-5 inline-flex items-center justify-center rounded-xl border border-emerald-300 bg-white px-4 py-2 text-sm font-semibold text-emerald-800 transition hover:bg-emerald-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-emerald-800 dark:bg-slate-900 dark:text-emerald-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Choose a different one
</button>
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit} noValidate>
{/* Password field */}
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<label htmlFor={pwId} className="text-sm font-semibold text-slate-800 dark:text-slate-200">
New password
</label>
<div className="flex items-center gap-2">
<button type="button" onClick={handleGenerate} className={pillBtn}>
<RefreshIcon />
Generate
</button>
<button
type="button"
onClick={handleCopy}
disabled={password.length === 0}
className={pillBtn}
aria-live="polite"
>
<CopyIcon />
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="relative">
<input
id={pwId}
name="new-password"
type={revealPw ? "text" : "password"}
value={password}
autoComplete="new-password"
spellCheck={false}
placeholder="Type a strong password"
aria-describedby={`${meterId} ${reqId}`}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
setSubmitted(false);
}}
onKeyUp={handlePwCaps}
onKeyDown={handlePwCaps}
onBlur={() => setCapsLock(false)}
className={`${inputBase} ${
predictable
? "border-rose-300 focus-visible:border-rose-400 focus-visible:ring-rose-500/25 dark:border-rose-800"
: "border-slate-300 focus-visible:border-indigo-400 focus-visible:ring-indigo-500/25 dark:border-slate-700"
}`}
/>
<button
type="button"
onClick={() => setRevealPw((v) => !v)}
aria-pressed={revealPw}
aria-label={revealPw ? "Hide password" : "Show password"}
className={iconBtn}
>
{revealPw ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
{capsLock && (
<p className="fps-rise mt-2 flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400">
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
<path d="M10 2 3 9h3.5v5h7V9H17L10 2Z" />
</svg>
Caps Lock is on
</p>
)}
{/* Strength meter */}
<div id={meterId} role="status" aria-live="polite" className="mt-3">
<div className="flex gap-1.5" aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
<motion.div
className={`h-full origin-left rounded-full ${i < level ? tier.bar : ""}`}
initial={false}
animate={{ scaleX: i < level ? 1 : 0 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 260, damping: 26, delay: i * 0.04 }
}
style={{ width: "100%" }}
/>
</div>
))}
</div>
<div className="mt-2 flex items-center justify-between text-xs">
<span className={`flex items-center gap-1.5 font-semibold ${tier.text}`}>
{level === 4 && !reduce && (
<span className="fps-glow inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
)}
{tier.label}
</span>
<span className="text-slate-400 dark:text-slate-500">
{metCount}/5 checks
</span>
</div>
<span className="sr-only">{meterStatus}</span>
{predictable && (
<p className="mt-1.5 text-xs text-rose-600 dark:text-rose-400">
That looks like a commonly guessed password. Try something less predictable.
</p>
)}
</div>
{/* Requirements checklist */}
<ul id={reqId} className="mt-4 grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{REQUIREMENTS.map((req, i) => {
const ok = met[i] ?? false;
return (
<li key={req.id} className="flex items-center gap-2 text-sm">
<span
className={`grid h-4 w-4 flex-none place-items-center rounded-full transition-colors ${
ok
? "bg-emerald-500 text-white"
: "bg-slate-200 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
}`}
>
<span key={ok ? "ok" : "no"} className={ok ? "fps-pop" : undefined}>
{ok ? <CheckIcon /> : <DotIcon />}
</span>
</span>
<span
className={
ok
? "text-slate-700 dark:text-slate-300"
: "text-slate-500 dark:text-slate-400"
}
>
{req.label}
</span>
<span className="sr-only">{ok ? " met" : " not met"}</span>
</li>
);
})}
</ul>
</div>
{/* Confirm field */}
<div>
<label htmlFor={confirmId} className="mb-2 block text-sm font-semibold text-slate-800 dark:text-slate-200">
Confirm password
</label>
<div className="relative">
<input
id={confirmId}
name="confirm-password"
type={revealConfirm ? "text" : "password"}
value={confirm}
autoComplete="new-password"
spellCheck={false}
placeholder="Re-enter your password"
aria-invalid={mismatch}
aria-describedby={mismatch ? `${confirmId}-err` : undefined}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setConfirm(e.target.value);
setSubmitted(false);
}}
className={`${inputBase} ${
mismatch
? "border-rose-300 focus-visible:border-rose-400 focus-visible:ring-rose-500/25 dark:border-rose-800"
: matched
? "border-emerald-300 focus-visible:border-emerald-400 focus-visible:ring-emerald-500/25 dark:border-emerald-800"
: "border-slate-300 focus-visible:border-indigo-400 focus-visible:ring-indigo-500/25 dark:border-slate-700"
}`}
/>
<button
type="button"
onClick={() => setRevealConfirm((v) => !v)}
aria-pressed={revealConfirm}
aria-label={revealConfirm ? "Hide confirmation password" : "Show confirmation password"}
className={iconBtn}
>
{revealConfirm ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
{mismatch ? (
<p id={`${confirmId}-err`} className="fps-shake mt-2 text-xs font-medium text-rose-600 dark:text-rose-400">
Passwords don’t match yet.
</p>
) : matched ? (
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<CheckIcon />
Passwords match.
</p>
) : null}
</div>
<button
type="submit"
disabled={!canSubmit}
className="mt-1 inline-flex items-center justify-center rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-sm shadow-indigo-600/30 transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-slate-300 disabled:text-slate-500 disabled:shadow-none dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-800 dark:disabled:text-slate-500"
>
{canSubmit ? "Save password" : "Meet all requirements to continue"}
</button>
<p className="flex items-center justify-center gap-1.5 text-center text-xs text-slate-500 dark:text-slate-500">
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
<path fillRule="evenodd" d="M10 1a4 4 0 0 0-4 4v2H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-1V5a4 4 0 0 0-4-4Zm2 6V5a2 2 0 1 0-4 0v2h4Z" clipRule="evenodd" />
</svg>
Hashed with Argon2id before it ever reaches our database.
</p>
</form>
)}
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

