A password field with an icon-swap visibility toggle, a caps-lock warning, 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. Hidden while an error is shown. */
hint?: string;
/** Error message. Turns the field red, fades the message in, shakes once. */
error?: string;
/** Success state. Pass a string to also show a confirmation message. */
success?: boolean | string;
/** Show the 4-segment strength meter below the field. */
showStrength?: boolean;
/**
* Change this value to replay the shake even when the error message is
* unchanged (e.g. increment on every failed submit).
*/
shakeKey?: string | number;
}
/* Strength scale shared with password-generator: destructive → warning →
* primary → success. Neutral until the password is genuinely strong. */
const STRENGTH = [
{ label: "", bar: "", text: "text-muted-foreground" },
{ label: "Weak", bar: "bg-destructive", text: "text-destructive" },
{ label: "Fair", bar: "bg-warning", text: "text-warning" },
{ label: "Good", bar: "bg-primary", text: "text-primary" },
{ label: "Strong", bar: "bg-success", text: "text-success" },
] 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, a caps-lock warning,
* and an optional strength meter: four segments fill with 50ms staggered
* delays, the color steps destructive → warning → primary → success, and the
* strength label crossfades. Errors shake once, matching the Input recipe.
*/
export function PasswordInput({
label,
hint,
error,
success,
showStrength = false,
shakeKey,
className,
id: idProp,
value,
defaultValue,
onChange,
onKeyDown,
onKeyUp,
onBlur,
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 [capsLock, setCapsLock] = React.useState(false);
const [internal, setInternal] = React.useState(String(defaultValue ?? ""));
const current = value !== undefined ? String(value) : internal;
const score = scorePassword(current);
const [shaking, setShaking] = React.useState(false);
React.useEffect(() => {
if (error) setShaking(true);
}, [error, shakeKey]);
const invalid = Boolean(error);
const successful = !invalid && Boolean(success);
const capsMessage = capsLock && !disabled ? "Caps Lock is on." : undefined;
const message =
error ??
capsMessage ??
(typeof success === "string" ? success : undefined) ??
hint;
const syncCapsLock = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (typeof event.getModifierState === "function") {
setCapsLock(event.getModifierState("CapsLock"));
}
};
return (
<div className="w-full">
<style href="paragon-password-input" precedence="paragon">{`
@keyframes paragon-password-shake {
0% { translate: 0; animation-timing-function: cubic-bezier(0.36, 0, 0.66, 0.2); }
25% { translate: -6px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
50% { translate: 5px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
75% { translate: -3px 0; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
100% { translate: 0; }
}
@keyframes paragon-password-message-in {
from { opacity: 0; translate: 0 -2px; filter: blur(2px); }
}
@media (prefers-reduced-motion: reduce) {
.paragon-password-shake { animation: none !important; }
.paragon-password-message { animation: none !important; }
}
`}</style>
{label && (
<label
htmlFor={id}
className="mb-1.5 block text-sm font-medium text-foreground"
>
{label}
</label>
)}
<div
className={cn("relative", shaking && "paragon-password-shake")}
style={
shaking
? { animation: "paragon-password-shake 280ms both" }
: undefined
}
onAnimationEnd={(event) => {
if (event.animationName === "paragon-password-shake") {
setShaking(false);
}
}}
>
<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);
}}
onKeyDown={(event) => {
syncCapsLock(event);
onKeyDown?.(event);
}}
onKeyUp={(event) => {
syncCapsLock(event);
onKeyUp?.(event);
}}
onBlur={(event) => {
setCapsLock(false);
onBlur?.(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",
successful &&
"border-success/60 focus-visible:border-success focus-visible:ring-success/20",
!invalid &&
!successful &&
"hover:not-disabled:not-focus-visible:border-ring/60",
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 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring 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].bar : "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[score].text,
)}
>
<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}
// Keyed so a new message re-runs the fade-in; the shake is a
// separate class on the field wrapper, so replaying it never
// flickers this element.
key={message}
className={cn(
"paragon-password-message mt-1.5 text-[13px]",
invalid
? "text-destructive"
: capsMessage && message === capsMessage
? "text-warning"
: successful && typeof success === "string"
? "text-success"
: "text-muted-foreground",
)}
style={{
animation: "paragon-password-message-in 200ms var(--ease-out) both",
}}
>
{message}
</p>
)}
</div>
);
}