A password field with a four-bar strength meter and requirements that tick themselves off as you type.
Customize
Usage
import { PasswordStrength } from "@/components/motionkit/password-strength";
<form action={signUp}>
<PasswordStrength />
</form>Motion
The exact curves this component runs, plotted with Motion's own maths.
Bar fill
A rule is met
Tick draw
A rule is met
Sign-up, reset-password and account settings forms, anywhere people choose a password and benefit from live guidance.
Each rule is a label and a test. On every keystroke the component counts the rules met and scales that to four bars, which fill on a spring with a short stagger and change colour from rust to teal. Each rule's tick is an SVG path drawn with pathLength as it's met. The eye button swaps the input type and draws a slash across itself. The strength word sits in a polite live region, so it's announced without interrupting typing.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://motionkitui.com/r/password-strength.jsonnpm install motionSave as components/motionkit/password-strength.tsx.
"use client";
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
export type PasswordRule = { label: string; test: (value: string) => boolean };
type PasswordStrengthProps = {
/** Visible label, also the accessible name. */
label?: string;
/** Requirements shown under the field. Each one ticks as it is met. */
rules?: PasswordRule[];
/** Called with the password and how many rules it meets. */
onValueChange?: (value: string, met: number) => void;
/** Form field name, so it submits with a normal form. */
name?: string;
/** "new-password" lets password managers offer to generate one. */
autoComplete?: string;
className?: string;
};
export const defaultRules: PasswordRule[] = [
{ label: "At least 10 characters", test: (v) => v.length >= 10 },
{ label: "Upper and lower case letters", test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v) },
{ label: "At least one number", test: (v) => /\d/.test(v) },
{ label: "At least one symbol", test: (v) => /[^A-Za-z0-9]/.test(v) },
];
// four steps, from rust to teal, so the colour tells the story before the word does
const levels = [
{ word: "Too weak", color: "#8C3216" },
{ word: "Weak", color: "#C0603F" },
{ word: "Fair", color: "#C9A26B" },
{ word: "Good", color: "#7D9E9D" },
{ word: "Strong", color: "#5FAEB4" },
];
/** A password field with a four-bar strength meter and requirements that tick themselves off as you type. */
export function PasswordStrength({
label = "Password",
rules = defaultRules,
onValueChange,
name = "password",
autoComplete = "new-password",
className = "",
}: PasswordStrengthProps) {
const id = useId();
const reduceMotion = useReducedMotion();
const [value, setValue] = useState("");
const [visible, setVisible] = useState(false);
const passed = rules.map((rule) => rule.test(value));
const met = passed.filter(Boolean).length;
// the meter is scaled to four bars whatever the number of rules
const score = value ? Math.max(1, Math.round((met / rules.length) * 4)) : 0;
const level = levels[score];
return (
<div className={`w-full max-w-sm text-[#F6F1E8] ${className}`}>
<label htmlFor={id} className="mb-2 block text-sm text-[#DCE3E2]">
{label}
</label>
<div className="relative">
<input
id={id}
name={name}
type={visible ? "text" : "password"}
value={value}
autoComplete={autoComplete}
aria-describedby={`${id}-strength ${id}-rules`}
onChange={(event) => {
setValue(event.target.value);
onValueChange?.(event.target.value, rules.filter((rule) => rule.test(event.target.value)).length);
}}
className="w-full rounded-xl border border-white/10 bg-[#141B1B] py-3 pl-4 pr-12 text-sm outline-none transition-colors placeholder:text-[#5E7271] focus:border-[#5FAEB4]/60"
placeholder="Choose a password"
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
aria-label={visible ? "Hide password" : "Show password"}
aria-pressed={visible}
className="absolute right-2 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-lg text-[#94A9A7] transition-colors hover:text-[#F6F1E8] focus-visible:outline-2 focus-visible:outline-[#5FAEB4]"
>
<svg aria-hidden viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<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" />
{/* the slash draws across the eye when the password is showing */}
<motion.path d="M4 4l16 16" initial={false} animate={{ pathLength: visible ? 1 : 0, opacity: visible ? 1 : 0 }} transition={{ duration: reduceMotion ? 0 : 0.25 }} />
</svg>
</button>
</div>
<div aria-hidden className="mt-3 grid grid-cols-4 gap-1.5">
{[1, 2, 3, 4].map((bar) => (
<div key={bar} className="h-1.5 overflow-hidden rounded-full bg-white/10">
<motion.div
className="h-full origin-left rounded-full"
initial={false}
animate={{ scaleX: bar <= score ? 1 : 0, backgroundColor: level.color }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 26, delay: (bar - 1) * 0.04 }}
/>
</div>
))}
</div>
<p id={`${id}-strength`} aria-live="polite" className="mt-2 h-4 text-xs" style={{ color: value ? level.color : "#5E7271" }}>
{value ? `Strength: ${level.word}` : ""}
</p>
<ul id={`${id}-rules`} className="mt-3 space-y-2 text-sm">
{rules.map((rule, index) => (
<li key={rule.label} className={`flex items-center gap-2.5 transition-colors ${passed[index] ? "text-[#DCE3E2]" : "text-[#94A9A7]"}`}>
<span aria-hidden className="relative flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors" style={{ borderColor: passed[index] ? "#5FAEB4" : "rgb(255 255 255 / 0.2)", backgroundColor: passed[index] ? "#5FAEB4" : "transparent" }}>
<svg viewBox="0 0 16 16" className="size-2.5" fill="none" stroke="#0C1010" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M3.5 8.5l3 3 6-7" initial={false} animate={{ pathLength: passed[index] ? 1 : 0 }} transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }} />
</svg>
</span>
{rule.label}
<span className="sr-only">{passed[index] ? "(met)" : "(not met)"}</span>
</li>
))}
</ul>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | "Password" | Visible label. |
| rules | PasswordRule[] | defaultRules | Requirements, each a label and a test function. |
| onValueChange | (value: string, met: number) => void | — | Called with the password and how many rules it meets. |
| name | string | "password" | Form field name. |
| autoComplete | string | "new-password" | Lets password managers offer a generated password. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Only the motion package, beside React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://motionkitui.com/r/password-strength.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, bars and ticks change instantly instead of filling and drawing.
Elastic Slider
A range slider with give: the value bubble leans as you drag, and pulling past the ends stretches the track like rubber.
Liquid Toggle
A switch whose knob stretches like liquid on its way across, then settles.
Code Input
One-time-code boxes where each digit pops in, with paste and phone autofill built in.