Password Strength
Live strength meter with a rule checklist and show/hide toggle.
Start typing a password
- At least 8 characters
- An uppercase letter
- A number
- A symbol
components/ui/password-strength.tsx
"use client";
import { motion } from "motion/react";
import { Check, Eye, EyeOff, X } from "lucide-react";
import { useId, useState } from "react";
const rules = [
{ label: "At least 8 characters", test: (p: string) => p.length >= 8 },
{ label: "An uppercase letter", test: (p: string) => /[A-Z]/.test(p) },
{ label: "A number", test: (p: string) => /\d/.test(p) },
{ label: "A symbol", test: (p: string) => /[^A-Za-z0-9]/.test(p) },
];
const levels = [
{ label: "Too weak", color: "#e11d48" },
{ label: "Weak", color: "#f97316" },
{ label: "Fair", color: "#eab308" },
{ label: "Good", color: "#22c55e" },
{ label: "Strong", color: "#16a34a" },
];
export function PasswordStrength({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [show, setShow] = useState(false);
const id = useId();
const score = rules.filter((r) => r.test(value)).length;
const level = levels[score];
return (
<div className="w-full">
<label htmlFor={id} className="text-sm font-medium text-zinc-900">
Password
</label>
<div className="relative mt-1.5">
<input
id={id}
type={show ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="new-password"
className="w-full rounded-xl border border-zinc-200 px-4 py-3 pr-11 text-sm outline-none transition focus:border-blue-600 focus:ring-4 focus:ring-blue-600/10"
/>
<button type="button" onClick={() => setShow((s) => !s)} aria-label={show ? "Hide password" : "Show password"} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-700">
{show ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
<div className="mt-3 flex gap-1.5" aria-hidden>
{rules.map((_, i) => (
<div key={i} className="h-1.5 flex-1 overflow-hidden rounded-full bg-zinc-100">
<motion.div className="h-full rounded-full" animate={{ width: i < score ? "100%" : "0%", backgroundColor: level.color }} transition={{ duration: 0.3 }} />
</div>
))}
</div>
<p className="mt-2 text-xs font-medium" style={{ color: value ? level.color : "#a1a1aa" }} aria-live="polite">
{value ? level.label : "Start typing a password"}
</p>
<ul className="mt-3 space-y-1.5">
{rules.map((r) => {
const ok = r.test(value);
return (
<li key={r.label} className={`flex items-center gap-2 text-xs transition-colors ${ok ? "text-emerald-600" : "text-zinc-500"}`}>
{ok ? <Check className="size-3.5" /> : <X className="size-3.5 text-zinc-300" />} {r.label}
</li>
);
})}
</ul>
</div>
);
}
export default function Demo() {
const [pw, setPw] = useState("");
return (
<div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-6">
<PasswordStrength value={pw} onChange={setPw} />
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/password-strength.tsxcomponents/ui/password-strength.tsx"use client"; import { motion } from "motion/react"; import { Check, Eye, EyeOff, X } from "lucide-react"; import { useId, useState } from "react"; const rules = [ { label: "At least 8 characters", test: (p: string) => p.length >= 8 }, { label: "An uppercase letter", test: (p: string) => /[A-Z]/.test(p) }, { label: "A number", test: (p: string) => /\d/.test(p) }, { label: "A symbol", test: (p: string) => /[^A-Za-z0-9]/.test(p) }, ]; const levels = [ { label: "Too weak", color: "#e11d48" }, { label: "Weak", color: "#f97316" }, { label: "Fair", color: "#eab308" }, { label: "Good", color: "#22c55e" }, { label: "Strong", color: "#16a34a" }, ]; export function PasswordStrength({ value, onChange }: { value: string; onChange: (v: string) => void }) { const [show, setShow] = useState(false); const id = useId(); const score = rules.filter((r) => r.test(value)).length; const level = levels[score]; return ( <div className="w-full"> <label htmlFor={id} className="text-sm font-medium text-zinc-900"> Password </label> <div className="relative mt-1.5"> <input id={id} type={show ? "text" : "password"} value={value} onChange={(e) => onChange(e.target.value)} autoComplete="new-password" className="w-full rounded-xl border border-zinc-200 px-4 py-3 pr-11 text-sm outline-none transition focus:border-blue-600 focus:ring-4 focus:ring-blue-600/10" /> <button type="button" onClick={() => setShow((s) => !s)} aria-label={show ? "Hide password" : "Show password"} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-700"> {show ? <EyeOff className="size-4" /> : <Eye className="size-4" />} </button> </div> <div className="mt-3 flex gap-1.5" aria-hidden> {rules.map((_, i) => ( <div key={i} className="h-1.5 flex-1 overflow-hidden rounded-full bg-zinc-100"> <motion.div className="h-full rounded-full" animate={{ width: i < score ? "100%" : "0%", backgroundColor: level.color }} transition={{ duration: 0.3 }} /> </div> ))} </div> <p className="mt-2 text-xs font-medium" style={{ color: value ? level.color : "#a1a1aa" }} aria-live="polite"> {value ? level.label : "Start typing a password"} </p> <ul className="mt-3 space-y-1.5"> {rules.map((r) => { const ok = r.test(value); return ( <li key={r.label} className={`flex items-center gap-2 text-xs transition-colors ${ok ? "text-emerald-600" : "text-zinc-500"}`}> {ok ? <Check className="size-3.5" /> : <X className="size-3.5 text-zinc-300" />} {r.label} </li> ); })} </ul> </div> ); } export default function Demo() { const [pw, setPw] = useState(""); return ( <div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-6"> <PasswordStrength value={pw} onChange={setPw} /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.