Password Input
A password input with show/hide toggle, a strength indicator, and an optional rule checklist.
- At least 8 characters
- One uppercase letter
- One lowercase letter
- One number
- One special character
import { PasswordInput, PasswordInputRules, PasswordInputStrengthChecker,} from "@/components/ui/password-input"";
const rules = [ { label: "At least 8 characters", test: (p: string) => p.length >= 8 }, { label: "One uppercase letter", test: (p: string) => /[A-Z]/.test(p) }, { label: "One lowercase letter", test: (p: string) => /[a-z]/.test(p) }, { label: "One number", test: (p: string) => /[0-9]/.test(p) }, { label: "One special character", test: (p: string) => /[^a-zA-Z0-9]/.test(p), },];
export function PasswordInputWithRules() { return ( <div className="w-full max-w-sm"> <PasswordInput placeholder="Enter password"> <PasswordInputStrengthChecker /> <PasswordInputRules rules={rules} /> </PasswordInput> </div> );}Installation
Section titled “Installation”This component relies on other items which must be installed first
Install the following dependencies
Copy and paste the following code into your project.
components/ui/password-input.tsx
"use client";
import { ZxcvbnFactory, type OptionsType } from "@zxcvbn-ts/core";import { CircleCheckIcon, CircleIcon, EyeIcon, EyeOffIcon } from "lucide-react";import { type ChangeEvent, type ComponentProps, createContext, type ReactNode, useContext, useDeferredValue, useEffect, useMemo, useRef, useState,} from "react";import type { Input } from "@/components/ui/input";import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput,} from "@/components/ui/input-group";import { cn } from "@/lib/utils";
const PasswordInputContext = createContext<{ password: string } | null>(null);
export function PasswordInput({ children, onChange, value, defaultValue, ...props}: Omit<ComponentProps<typeof Input>, "type"> & { children?: ReactNode;}) { const [showPassword, setShowPassword] = useState(false); const [password, setPassword] = useState(defaultValue ?? "");
const Icon = showPassword ? EyeOffIcon : EyeIcon; const currentValue = value ?? password;
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setPassword(e.target.value); onChange?.(e); };
return ( <PasswordInputContext value={{ password: currentValue.toString() }}> <div className="space-y-3"> <InputGroup> <InputGroupInput {...props} value={value} defaultValue={defaultValue} type={showPassword ? "text" : "password"} onChange={handleChange} /> <InputGroupAddon align="inline-end"> <InputGroupButton size="icon-xs" onClick={() => setShowPassword((p) => !p)} > <Icon className="size-4.5" /> <span className="sr-only"> {showPassword ? "Hide password" : "Show password"} </span> </InputGroupButton> </InputGroupAddon> </InputGroup> {children} </div> </PasswordInputContext> );}
export function PasswordInputStrengthChecker({ options, userInputs, threshold = 3, showFeedback = true, onScoreChange,}: { options?: OptionsType; userInputs?: (string | number)[]; threshold?: 0 | 1 | 2 | 3 | 4; showFeedback?: boolean; onScoreChange?: (score: 0 | 1 | 2 | 3 | 4) => void;}) { const [zxcvbn, setZxcvbn] = useState<InstanceType< typeof ZxcvbnFactory > | null>(null); const [errorLoadingOptions, setErrorLoadingOptions] = useState(false); const optionsRef = useRef(options); const onScoreChangeRef = useRef(onScoreChange); onScoreChangeRef.current = onScoreChange;
const { password } = usePasswordInput(); const deferredPassword = useDeferredValue(password);
const strengthResult = useMemo(() => { if (!zxcvbn || deferredPassword.length === 0) { return { score: 0 as const, feedback: { warning: undefined as string | undefined, suggestions: [] as string[], }, }; } return zxcvbn.check(deferredPassword, userInputs); }, [zxcvbn, deferredPassword, userInputs]);
useEffect(() => { const opts = optionsRef.current; if (opts) { if (opts.translations) { setZxcvbn(new ZxcvbnFactory(opts)); } else { import("@zxcvbn-ts/language-en") .then((english) => { setZxcvbn( new ZxcvbnFactory({ translations: english.translations, ...opts, }), ); }) .catch(() => setErrorLoadingOptions(true)); } return; } Promise.all([ import("@zxcvbn-ts/language-common"), import("@zxcvbn-ts/language-en"), ]) .then(([common, english]) => { setZxcvbn( new ZxcvbnFactory({ translations: english.translations, graphs: common.adjacencyGraphs, dictionary: { ...common.dictionary, ...english.dictionary, }, }), ); }) .catch(() => setErrorLoadingOptions(true)); }, []);
useEffect(() => { onScoreChangeRef.current?.(strengthResult.score); }, [strengthResult.score]);
function getLabel() { if (deferredPassword.length === 0) return "Password strength"; if (!zxcvbn) return "Checking..."; switch (strengthResult.score) { case 0: case 1: return "Very weak"; case 2: return "Weak"; case 3: return "Strong"; case 4: return "Very strong"; default: return "Unknown"; } }
const label = getLabel(); const color = strengthResult.score >= threshold ? "bg-primary" : "bg-destructive"; const { warning, suggestions } = strengthResult.feedback; const hasFeedback = showFeedback && deferredPassword.length > 0 && (warning != null || suggestions.length > 0);
if (errorLoadingOptions) return null;
return ( <div className="space-y-1.5"> <div role="progressbar" aria-label="Password strength" aria-valuenow={strengthResult.score} aria-valuemin={0} aria-valuemax={4} aria-valuetext={label} className="flex gap-1" > {([0, 1, 2, 3] as const).map((level) => ( <div key={`strength-bar-${level}`} className={cn( "h-1 flex-1 rounded-full transition-colors", strengthResult.score > level ? color : "bg-secondary", )} /> ))} </div> <div className="flex justify-end text-sm text-muted-foreground"> {label} </div> {hasFeedback && ( <div className="space-y-0.5 text-sm text-muted-foreground"> {warning && <p>{warning}</p>} {suggestions.map((s, i) => ( <p key={i}>{s}</p> ))} </div> )} </div> );}
export type PasswordRule = { label: string; test: (password: string) => boolean;};
export function PasswordInputRules({ rules }: { rules: PasswordRule[] }) { const { password } = usePasswordInput(); const idle = password.length === 0;
return ( <ul className="space-y-1"> {rules.map((rule, index) => { const passed = !idle && rule.test(password); return ( <li key={index} className={cn( "flex items-center gap-2 text-sm transition-colors", passed ? "text-primary" : "text-muted-foreground", )} > {passed ? ( <CircleCheckIcon className="size-3.5 shrink-0" /> ) : ( <CircleIcon className="size-3.5 shrink-0" /> )} {rule.label} </li> ); })} </ul> );}
const usePasswordInput = () => { const context = useContext(PasswordInputContext); if (context == null) { throw new Error( "usePasswordInput must be used within a PasswordInputContext", ); } return context;};Update the import paths to match your project setup.
import { PasswordInput, PasswordInputRules, PasswordInputStrengthChecker,} from "@/components/ui/password-input";<PasswordInput placeholder="Enter password"> <PasswordInputStrengthChecker /></PasswordInput>PasswordInput is a compound component. PasswordInputStrengthChecker and PasswordInputRules must be rendered as children , they read the current value through context. Both are optional and can be used independently or together.
How the strength score works
Section titled “How the strength score works”The strength checker is powered by zxcvbn, which scores passwords from 0 to 4 based on estimated crack time , not just character rules. It detects keyboard patterns, common words, repeated characters, and dictionary entries.
| Score | Label | What it means |
|---|---|---|
| 0 | Very weak | Instant crack e.g common words, |
| 1 | Very weak | Guessable with a short online attack |
| 2 | Weak | Needs a targeted attack; not safe for sensitive data |
| 3 | Strong | Resistant to most online attacks |
| 4 | Very strong | Resistant to offline dictionary attacks |
The four bars in the UI represent scores 1–4. Each bar fills with bg-primary when score > barIndex, and turns bg-destructive if the score is still below threshold. Once the score meets or exceeds threshold, all filled bars switch to bg-primary.
The default threshold is 3 , it requiring a “Strong” password before bars go green. Raise it to 4 for stricter requirements; lower it for less critical flows.
Caution
Section titled “Caution”Color indicator and threshold is only use to relfect as ui indicator not handle rejective logic. It’s the job of onScoreChange to check whether password should be acceptance or not
Examples
Section titled “Examples”import { PasswordInput, PasswordInputStrengthChecker,} from "@/components/ui/password-input"";
export function BasicPasswordInput() { return ( <div className="w-full max-w-sm"> <PasswordInput placeholder="Enter password"> <PasswordInputStrengthChecker /> </PasswordInput> </div> );}With Policy Rules
Section titled “With Policy Rules”Combine the strength indicator with a rule checklist to cover both entropy feedback and your specific requirements. Each rule is a plain { label, test } object , write the label in plain language and the test as a predicate over the password string. Rules turn from muted to primary as they pass.
import { PasswordInput, PasswordInputRules, PasswordInputStrengthChecker,} from "@/components/ui/password-input"";
const rules = [ { label: "At least 8 characters", test: (p: string) => p.length >= 8 }, { label: "One uppercase letter", test: (p: string) => /[A-Z]/.test(p) }, { label: "One lowercase letter", test: (p: string) => /[a-z]/.test(p) }, { label: "One number", test: (p: string) => /[0-9]/.test(p) }, { label: "One special character", test: (p: string) => /[^a-zA-Z0-9]/.test(p), },];
export function PasswordInputWithRules() { return ( <div className="w-full max-w-sm"> <PasswordInput placeholder="Enter password"> <PasswordInputStrengthChecker /> <PasswordInputRules rules={rules} /> </PasswordInput> </div> );}Gating form submission
Section titled “Gating form submission”Use onScoreChange to read the live score outside the component. The callback fires whenever the score changes. This can disable the submit button until the score meets your threshold.
import { useState } from "react";import { Button } from "@/components/ui/button";import { PasswordInput, PasswordInputStrengthChecker,} from "@/components/ui/password-input"";
const THRESHOLD = 3;
export function PasswordInputWithForm() { const [score, setScore] = useState<0 | 1 | 2 | 3 | 4>(0);
return ( <form className="w-full max-w-sm space-y-4" onSubmit={(e) => e.preventDefault()} > <PasswordInput placeholder="Create password"> <PasswordInputStrengthChecker threshold={THRESHOLD} onScoreChange={setScore} /> </PasswordInput> <Button type="submit" className="w-full" disabled={score < THRESHOLD}> Create account </Button> </form> );}You can also use the score inside PasswordInputRules by running zxcvbn directly in a rule’s test function:
import { ZxcvbnFactory } from "@zxcvbn-ts/core";
const zxcvbn = new ZxcvbnFactory({ /* your options */});
const rules = [ { label: "At least 8 characters", test: (p) => p.length >= 8 }, { label: "One uppercase letter", test: (p) => /[A-Z]/.test(p) }, { label: "Strong password", test: (p) => zxcvbn.check(p).score >= 3 },];Advanced
Section titled “Advanced”Those customization concept below can also be found in zxcvbn-ts docs
Custom options
Section titled “Custom options”The default strength checker loads English dictionaries. Use the options and userInputs props to go further.
These two props serve different purposes and have different lifetimes:
options: static configuration passed to theZxcvbnFactoryconstructor. Read once at mount. Use it for custom dictionaries, keyboard graphs, and Levenshtein settings.userInputs: dynamic per-user context passed tozxcvbn.check()on every evaluation. Memoize it so the score only recomputes when the values actually change.
The table below shows the three detection layers options enables:
| What it catches | How | Config |
|---|---|---|
| Exact dictionary match | dictionary.banned: [“acmecorp”] |
| l33t substitution always on for dictionary entries, no config needed | — |
| Levenshtein distance on the full password | useLevenshteinDistance: true |
| Password contains the user’s own name or email | Per-check user context | |
Try: your username, acm3corp, @cm3c0rp, acmecorpz
"use client";
import { useMemo, useState } from "react";import * as english from "@zxcvbn-ts/language-en";import type { OptionsType } from "@zxcvbn-ts/core";import { Input } from "@/components/ui/input";import { PasswordInput, PasswordInputStrengthChecker,} from "@/components/ui/password-input"";
const BANNED_TERMS = ["acmecorp", "welcome", "letmein", "admin", "kaui"];
const OPTIONS: OptionsType = { translations: { ...english.translations, warnings: { ...english.translations.warnings, userInputs: "Avoid using your name or email in your password.", common: "This password is too common.", }, suggestions: { ...english.translations.suggestions, anotherWord: "Add an uncommon word to make it harder to guess.", }, }, dictionary: { banned: BANNED_TERMS }, useLevenshteinDistance: true, levenshteinThreshold: 2,};
export function PasswordInputWithCustomOptions() { const [username, setUsername] = useState("");
const userInputs = useMemo(() => (username ? [username] : []), [username]);
return ( <div className="w-full max-w-sm space-y-4"> <div className="space-y-2"> <label className="text-sm font-medium">Username</label> <Input placeholder="e.g. johndoe" value={username} onChange={(e) => setUsername(e.target.value)} /> </div> <div className="space-y-2"> <label className="text-sm font-medium">Password</label> <PasswordInput placeholder="Create password"> <PasswordInputStrengthChecker options={OPTIONS} userInputs={userInputs} /> </PasswordInput> </div> <p className="text-xs text-muted-foreground"> Try: your username, <code>acm3corp</code>, <code>@cm3c0rp</code>,{" "} <code>acmecorpz</code> </p> </div> );}If options is provided without a translations key, the component automatically loads and merges English translations so raw keys like "userInputs" or "anotherWord" never appear. To customise specific messages, spread the English base and override only the keys you need:
import * as english from "@zxcvbn-ts/language-en";import type { OptionsType } from "@zxcvbn-ts/core";
const OPTIONS: OptionsType = { translations: { ...english.translations, warnings: { ...english.translations.warnings, userInputs: "Avoid using your name or email in your password.", }, suggestions: { ...english.translations.suggestions, anotherWord: "Add an uncommon word to make it harder to guess.", }, }, dictionary: { banned: ["acmecorp", "welcome", "letmein", "admin"] }, useLevenshteinDistance: true, levenshteinThreshold: 2,};The complete set of overridable keys:
| Type | Key | Default English text |
|---|---|---|
| warning | straightRow | Straight rows of keys on your keyboard are easy to guess. |
| warning | keyPattern | Short keyboard patterns are easy to guess. |
| warning | simpleRepeat | Repeated characters like “aaa” are easy to guess. |
| warning | extendedRepeat | Repeated character patterns like “abcabcabc” are easy to guess. |
| warning | sequences | Common character sequences like “abc” are easy to guess. |
| warning | recentYears | Recent years are easy to guess. |
| warning | dates | Dates are easy to guess. |
| warning | topTen | This is a heavily used password. |
| warning | topHundred | This is a frequently used password. |
| warning | common | This is a commonly used password. |
| warning | similarToCommon | This is similar to a commonly used password. |
| warning | wordByItself | Single words are easy to guess. |
| warning | namesByThemselves | Single names or surnames are easy to guess. |
| warning | commonNames | Common names and surnames are easy to guess. |
| warning | userInputs | There should not be any personal or page related data. |
| warning | pwned | Your password was exposed by a data breach on the Internet. |
| suggestion | l33t | Avoid predictable letter substitutions like ’@’ for ‘a’. |
| suggestion | reverseWords | Avoid reversed spellings of common words. |
| suggestion | allUppercase | Capitalize some, but not all letters. |
| suggestion | capitalization | Capitalize more than the first letter. |
| suggestion | dates | Avoid dates and years that are associated with you. |
| suggestion | recentYears | Avoid recent years. |
| suggestion | associatedYears | Avoid years that are associated with you. |
| suggestion | sequences | Avoid common character sequences. |
| suggestion | repeated | Avoid repeated words and characters. |
| suggestion | longerKeyboardPattern | Use longer keyboard patterns and change typing direction multiple times. |
| suggestion | anotherWord | Add more words that are less common. |
| suggestion | useWords | Use multiple words, but avoid common phrases. |
| suggestion | noNeed | You can create strong passwords without using symbols, numbers, or uppercase letters. |
| suggestion | pwned | If you use this password elsewhere, you should change it. |
Define OPTIONS outside the component so it is never recreated on re-renders. Use showFeedback={false} to hide the warning and suggestion block entirely.
Comprehensive Example
Section titled “Comprehensive Example”The built-in English dictionaries miss passwords from other languages (hallo123, passwort, willkommen) and cannot check whether a password has appeared in a known data breach. The example below combines every available layer into a realistic account-creation form.
What it demonstrates:
- Multi-language dictionaries : English and German word lists merged together. Install any
@zxcvbn-ts/language-*package and spread itsdictionaryexport intooptions. - Custom banned terms : company and product names added to the dictionary, detected with l33t substitution and Levenshtein automatically.
userInputsfrom form fields : username and email fed into everycheck()call viauseMemo. Passwords containing personal info score low instantly.- Mock breach check : debounced async lookup after 700 ms. Four states:
idle → checking → safe | breached. - Three gates on submit : the button only enables when
score >= thresholdANDbreach === "safe"AND all explicit rules pass. Each gate is independent.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";import * as common from "@zxcvbn-ts/language-common";import * as english from "@zxcvbn-ts/language-en";import * as german from "@zxcvbn-ts/language-de";import type { OptionsType } from "@zxcvbn-ts/core";import { CheckCircle2Icon, Loader2Icon, ShieldAlertIcon } from "lucide-react";import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { cn } from "@/lib/utils";import { PasswordInput, PasswordInputRules, PasswordInputStrengthChecker,} from "@/components/ui/password-input"";
// ---- zxcvbn options (defined at module level — never recreated) ----
const OPTIONS: OptionsType = { translations: english.translations, graphs: common.adjacencyGraphs, dictionary: { ...common.dictionary, ...english.dictionary, ...german.dictionary, banned: ["acmecorp", "welcome", "letmein", "admin"], }, useLevenshteinDistance: true, levenshteinThreshold: 2,};
// ---- rules ----
const RULES = [ { label: "At least 10 characters", test: (p: string) => p.length >= 10 }, { label: "One uppercase letter", test: (p: string) => /[A-Z]/.test(p) }, { label: "One number", test: (p: string) => /[0-9]/.test(p) }, { label: "One special character", test: (p: string) => /[^a-zA-Z0-9]/.test(p), },];
const THRESHOLD = 3;
// ---- mock breach check ----// In production: SHA-1 hash the password, send the first 5 chars to// api.pwnedpasswords.com/range/{prefix}, check if the suffix appears in the response.
const BREACHED = new Set([ "password", "password123", "password1", "123456", "12345678", "letmein", "qwerty", "abc123", "monkey", "dragon", "welcome", "admin", "iloveyou", "sunshine", "princess", "master",]);
async function mockBreachCheck(password: string): Promise<boolean> { await new Promise((r) => setTimeout(r, 700)); return BREACHED.has(password.toLowerCase());}
// ---- component ----
type BreachState = "idle" | "checking" | "safe" | "breached";
export function AdvancedPasswordInput() { const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [score, setScore] = useState<0 | 1 | 2 | 3 | 4>(0); const [breach, setBreach] = useState<BreachState>("idle");
const userInputs = useMemo( () => [username, email].filter(Boolean), [username, email], );
const breachTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { if (breachTimer.current) clearTimeout(breachTimer.current);
if (password.length < 6) { setBreach("idle"); return; }
setBreach("checking"); breachTimer.current = setTimeout(() => { mockBreachCheck(password).then((hit) => setBreach(hit ? "breached" : "safe"), ); }, 700);
return () => { if (breachTimer.current) clearTimeout(breachTimer.current); }; }, [password]);
const allRulesPassed = RULES.every((r) => r.test(password)); const canSubmit = score >= THRESHOLD && breach === "safe" && allRulesPassed;
return ( <form className="w-full max-w-sm space-y-4" onSubmit={(e) => e.preventDefault()} > <div className="space-y-2"> <label className="text-sm font-medium">Username</label> <Input placeholder="johndoe" value={username} onChange={(e) => setUsername(e.target.value)} /> </div> <div className="space-y-2"> <label className="text-sm font-medium">Email</label> <Input type="email" placeholder="john@example.com" value={email} onChange={(e) => setEmail(e.target.value)} /> </div> <div className="space-y-2"> <label className="text-sm font-medium">Password</label> <PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Create password" > <PasswordInputStrengthChecker options={OPTIONS} userInputs={userInputs} threshold={THRESHOLD} onScoreChange={setScore} /> <BreachIndicator state={breach} /> <PasswordInputRules rules={RULES} /> </PasswordInput> </div> <Button type="submit" disabled={!canSubmit} className="w-full"> Create account </Button> </form> );}
function BreachIndicator({ state }: { state: BreachState }) { if (state === "idle") return null;
return ( <div className={cn("flex items-center gap-2 text-sm transition-colors", { "text-muted-foreground": state === "checking", "text-primary": state === "safe", "text-destructive": state === "breached", })} > {state === "checking" && ( <Loader2Icon className="size-3.5 shrink-0 animate-spin" /> )} {state === "safe" && <CheckCircle2Icon className="size-3.5 shrink-0" />} {state === "breached" && ( <ShieldAlertIcon className="size-3.5 shrink-0" /> )} {state === "checking" && "Checking breach databases…"} {state === "safe" && "Not found in known breaches"} {state === "breached" && "Found in known data breaches , please choose a different password"} </div> );}import * as common from "@zxcvbn-ts/language-common";import * as english from "@zxcvbn-ts/language-en";import * as german from "@zxcvbn-ts/language-de";import type { OptionsType } from "@zxcvbn-ts/core";
const OPTIONS: OptionsType = { translations: english.translations, graphs: common.adjacencyGraphs, dictionary: { ...common.dictionary, ...english.dictionary, ...german.dictionary, // catches: hallo, passwort, willkommen… banned: ["acmecorp", "admin"], }, useLevenshteinDistance: true, levenshteinThreshold: 2,};PasswordInput
Section titled “PasswordInput”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | number | — | Controlled value. Omit to use uncontrolled mode |
defaultValue | string | number | — | Initial value in uncontrolled mode |
onChange | (e: ChangeEvent<HTMLInputElement>) => void | — | Called on every keystroke |
children | ReactNode | — | Slot for sub-components or any additional content below the input |
All other Input props are forwarded. The type prop is omitted — it is managed internally.
PasswordInputStrengthChecker
Section titled “PasswordInputStrengthChecker”| Prop | Type | Default | Description |
|---|---|---|---|
threshold | 0 | 1 | 2 | 3 | 4 | 3 | Minimum score considered acceptable. Bars at or above this score render in primary; below render in destructive |
showFeedback | boolean | true | Show or hide the warning and suggestion text below the bars. Set to
|
onScoreChange | (score: 0 | 1 | 2 | 3 | 4) => void | — | Called whenever the score changes. Use this to read the strength outside the component . For example, to disable a submit button until the score meets your threshold |
userInputs | (string | number)[] | — | Per-user context passed to every |
options | OptionsType | English dictionaries | Static configuration for the strength engine , custom dictionaries, keyboard
graphs, Levenshtein settings. Passed to the |
Must be rendered inside PasswordInput.
PasswordInputRules
Section titled “PasswordInputRules”| Prop | Type | Default | Description |
|---|---|---|---|
rules | PasswordRule[] | — | Required. List of rules to display and evaluate |
Each PasswordRule is { label: string; test: (password: string) => boolean }. The label is shown as-is in the checklist , write it in plain language your users will understand. The test predicate receives the current password and returns whether the rule passes.
Must be rendered inside PasswordInput.