Password Input
A password input with show/hide toggle and an optional strength indicator
src/registry/base/password-input/components/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;
};
No selection tags yet — empty means no evidence, not lack of support.
DependenciesBundle weight · light
- icons
- lucide-react