A password field with an icon-swap visibility toggle and an optional strength meter whose four segments fill with staggered delays as the label crossfades.
npx shadcn@latest add @paragon/password-input"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PasswordInputProps
extends Omit<React.ComponentProps<"input">, "type"> {
/** Visible label, wired via htmlFor. */
label?: string;
/** Helper text below the field. */
hint?: string;
/** Error message. Turns the field red. */
error?: string;
/** Show the 4-segment strength meter below the field. */
showStrength?: boolean;
}
const STRENGTH = [
{ label: "", color: "" },
{ label: "Weak", color: "bg-destructive" },
{ label: "Fair", color: "bg-amber-500" },
{ label: "Good", color: "bg-emerald-500" },
{ label: "Strong", color: "bg-emerald-500" },
] as const;
const STRENGTH_TEXT = [
"text-muted-foreground",
"text-destructive",
"text-amber-600 dark:text-amber-400",
"text-emerald-600 dark:text-emerald-400",
"text-emerald-600 dark:text-emerald-400",
] as const;
function scorePassword(password: string): number {
if (!password) return 0;
// Anything under 8 characters is weak no matter its alphabet.
if (password.length < 8) return 1;
let score = 1;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score += 1;
if (/\d/.test(password)) score += 1;
if (/[^A-Za-z0-9]/.test(password)) score += 1;
// "Strong" additionally requires 12+ characters.
if (password.length < 12) score = Math.min(score, 3);
return Math.min(score, 4);
}
/**
* Password field with an icon-swap visibility toggle and an optional
* strength meter: four segments fill with 50ms staggered delays, the color
* steps destructive → amber → green, and the strength label crossfades.
*/
export function PasswordInput({
label,
hint,
error,
showStrength = false,
className,
id: idProp,
value,
defaultValue,
onChange,
disabled,
"aria-describedby": ariaDescribedBy,
...props
}: PasswordInputProps) {
const autoId = React.useId();
const id = idProp ?? autoId;
const messageId = `${id}-message`;
const reducedMotion = useReducedMotion();
const [visible, setVisible] = React.useState(false);
const [internal, setInternal] = React.useState(String(defaultValue ?? ""));
const current = value !== undefined ? String(value) : internal;
const score = scorePassword(current);
const invalid = Boolean(error);
const message = error ?? hint;
return (
<div className="w-full">
{label && (
<label
htmlFor={id}
className="mb-1.5 block text-sm font-medium text-foreground"
>
{label}
</label>
)}
<div className="relative">
<input
id={id}
type={visible ? "text" : "password"}
value={value}
defaultValue={value === undefined ? defaultValue : undefined}
disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={
cn(message ? messageId : undefined, ariaDescribedBy) || undefined
}
onChange={(event) => {
if (value === undefined) setInternal(event.target.value);
onChange?.(event);
}}
className={cn(
"h-9 w-full min-w-0 rounded-lg border border-input bg-transparent pr-10 pl-3 text-sm text-foreground",
"transition-[border-color,box-shadow] duration-150 ease-out",
"placeholder:text-muted-foreground",
"outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
"disabled:cursor-not-allowed disabled:opacity-50",
invalid &&
"border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
className,
)}
{...props}
/>
<button
type="button"
aria-label={visible ? "Hide password" : "Show password"}
aria-pressed={visible}
disabled={disabled}
onClick={() => setVisible((v) => !v)}
className="absolute top-1/2 right-1.5 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:text-foreground disabled:pointer-events-none disabled:opacity-50 after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={visible ? "hide" : "show"}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="flex"
>
{visible ? (
<EyeOff aria-hidden className="size-4" />
) : (
<Eye aria-hidden className="size-4" />
)}
</motion.span>
</AnimatePresence>
</button>
</div>
{showStrength && (
<div className="mt-2 flex items-center gap-2">
<div aria-hidden className="flex grow gap-1">
{Array.from({ length: 4 }, (_, index) => {
const active = index < score;
return (
<span
key={index}
className={cn(
"h-1 flex-1 rounded-full transition-[background-color] duration-200 ease-out",
active ? STRENGTH[score].color : "bg-secondary",
)}
// Stagger fills left-to-right; empty immediately together.
style={{ transitionDelay: active ? `${index * 50}ms` : "0ms" }}
/>
);
})}
</div>
<span
role="status"
className={cn(
"relative flex h-4 w-12 items-center justify-end text-xs font-medium",
STRENGTH_TEXT[score],
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={STRENGTH[score].label || "empty"}
initial={{ opacity: 0, filter: "blur(2px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(2px)" }}
transition={{ type: "spring", duration: 0.25, bounce: 0 }}
>
{STRENGTH[score].label}
</motion.span>
</AnimatePresence>
</span>
</div>
)}
{message && (
<p
id={messageId}
role={invalid ? "alert" : undefined}
className={cn(
"mt-1.5 text-[13px]",
invalid ? "text-destructive" : "text-muted-foreground",
)}
>
{message}
</p>
)}
</div>
);
}