Password Input
Password field with an eye icon that morphs to eye-off, a caps-lock warning, a segmented strength meter whose bars spring-fill and recolor with a sliding Weak/Fair/Good/Strong label from a length-weighted NIST-style heuristic, an optional requirement checklist whose items check off with a draw-in tick, and onStrengthChange.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared password strength heuristic, used by SignUpForm and PasswordInput.
export const MIN_PASSWORD_LENGTH = 8;
/** Label per score, index 0-4. */
export const STRENGTH_LABELS = [
"Too short",
"Weak",
"Fair",
"Good",
"Strong",
] as const;
/** Fill color per score, index 0-4 (Tailwind background classes). */
export const STRENGTH_COLORS = [
"bg-destructive",
"bg-destructive",
"bg-amber-500",
"bg-amber-400",
"bg-(--color-success)",
] as const;
export type PasswordStrength = 0 | 1 | 2 | 3 | 4;
/**
* Length-weighted strength score, 0-4. NIST SP 800-63B advises against
* composition requirements and treats length as the dominant factor, so extra
* character classes only nudge the score — they can't rescue a short password.
* This is a heuristic for feedback, not entropy estimation; pair it with a
* breach-list check server-side for anything real.
*/
export function passwordStrength(
password: string,
minLength: number = MIN_PASSWORD_LENGTH,
): PasswordStrength {
if (password.length < minLength) return 0;
let score = 1;
if (password.length >= 12) score += 1;
if (password.length >= 16) score += 1;
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((pattern) =>
pattern.test(password),
).length;
if (classes >= 3) score += 1;
return Math.min(score, 4) as PasswordStrength;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/password-input
import { Lock } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
forwardRef,
type KeyboardEvent,
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { Input, type InputProps } from "@/components/motion/input";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import {
MIN_PASSWORD_LENGTH,
type PasswordStrength,
passwordStrength,
STRENGTH_COLORS,
STRENGTH_LABELS,
} from "@/lib/password-strength";
import { cn } from "@/lib/utils";
export type { PasswordStrength } from "@/lib/password-strength";
export type PasswordRequirement = {
id: string;
label: string;
test: (password: string) => boolean;
};
/** Default checklist: length, number, symbol, mixed case. */
export function defaultPasswordRequirements(
minLength: number = MIN_PASSWORD_LENGTH,
): PasswordRequirement[] {
return [
{
id: "length",
label: `At least ${minLength} characters`,
test: (password) => password.length >= minLength,
},
{ id: "number", label: "A number", test: (password) => /\d/.test(password) },
{
id: "symbol",
label: "A symbol",
test: (password) => /[^A-Za-z0-9\s]/.test(password),
},
{
id: "case",
label: "Upper and lowercase letters",
test: (password) => /[a-z]/.test(password) && /[A-Z]/.test(password),
},
];
}
const STRENGTH_TEXT = [
"text-destructive",
"text-destructive",
"text-amber-600 dark:text-amber-400",
"text-amber-600 dark:text-amber-400",
"text-(--color-success)",
] as const;
export interface PasswordInputProps
extends Omit<InputProps, "type" | "leftIcon" | "rightIcon" | "success"> {
/** Show the segmented strength meter once something is typed. Default true. */
strengthMeter?: boolean;
/**
* Requirement checklist under the field. `true` uses
* `defaultPasswordRequirements(minLength)`; pass an array for custom rules.
*/
requirements?: boolean | PasswordRequirement[];
/** Length below which the score is 0 ("Too short"). Default 8. */
minLength?: number;
/** Fires whenever the 0-4 score changes. */
onStrengthChange?: (strength: PasswordStrength, label: string) => void;
/** Allow revealing the password. Default true. */
revealable?: boolean;
/** Left icon. Defaults to a lock; pass `null` for none. */
icon?: ReactNode;
}
/**
* Password field over `Input`: eye/eye-off morphing reveal toggle, a caps-lock
* warning, a spring-filled strength meter driven by the shared
* `passwordStrength` heuristic, and an optional requirement checklist.
*/
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
function PasswordInput(
{
value: valueProp,
defaultValue,
onChange,
onKeyDown,
onKeyUp,
onBlur,
strengthMeter = true,
requirements = false,
minLength = MIN_PASSWORD_LENGTH,
onStrengthChange,
revealable = true,
icon,
disabled,
autoComplete,
placeholder = "Enter a password",
className,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const baseId = useId();
const statusId = `${baseId}-status`;
const controlled = valueProp !== undefined;
const [internal, setInternal] = useState(defaultValue ?? "");
const value = controlled ? valueProp : internal;
const [revealed, setRevealed] = useState(false);
const [capsLock, setCapsLock] = useState(false);
const strength = passwordStrength(value, minLength);
const reported = useRef<number | null>(null);
const strengthCallback = useRef(onStrengthChange);
strengthCallback.current = onStrengthChange;
useEffect(() => {
if (reported.current === strength) return;
reported.current = strength;
strengthCallback.current?.(strength, STRENGTH_LABELS[strength]);
}, [strength]);
const rules = useMemo(
() =>
requirements === true
? defaultPasswordRequirements(minLength)
: requirements || [],
[requirements, minLength],
);
const readCaps = (event: KeyboardEvent<HTMLInputElement>) => {
// getModifierState is missing on some synthetic/IME events.
if (typeof event.getModifierState !== "function") return;
setCapsLock(event.getModifierState("CapsLock"));
};
const showMeter = strengthMeter && value.length > 0;
return (
<div className={cn("flex w-full flex-col gap-2", className)}>
<Input
{...rest}
ref={ref}
type={revealed ? "text" : "password"}
value={value}
disabled={disabled}
placeholder={placeholder}
autoComplete={
autoComplete ?? (strengthMeter ? "new-password" : "current-password")
}
spellCheck={false}
aria-describedby={showMeter || capsLock ? statusId : undefined}
leftIcon={icon === undefined ? <Lock /> : icon}
onChange={(next) => {
if (!controlled) setInternal(next);
onChange?.(next);
}}
onKeyDown={(event) => {
readCaps(event);
onKeyDown?.(event);
}}
onKeyUp={(event) => {
readCaps(event);
onKeyUp?.(event);
}}
onBlur={(event) => {
setCapsLock(false);
onBlur?.(event);
}}
rightIcon={
revealable ? (
<button
type="button"
disabled={disabled}
onClick={() => setRevealed((prev) => !prev)}
aria-label={revealed ? "Hide password" : "Show password"}
aria-pressed={revealed}
className="text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground"
>
<EyeMorph closed={revealed} reduce={Boolean(reduce)} />
</button>
) : undefined
}
/>
<div id={statusId} className="flex flex-col gap-2 px-1">
<AnimatePresence initial={false}>
{capsLock ? (
<motion.p
key="caps"
role="status"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.1 } }
: { opacity: 0, y: -4, transition: { duration: 0.12 } }
}
transition={{ duration: 0.18, ease: EASE_OUT }}
className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400"
>
<CapsIcon />
Caps Lock is on
</motion.p>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{showMeter ? (
<motion.div
key="meter"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.1 } }
: { opacity: 0, y: -4, transition: { duration: 0.12 } }
}
transition={{ duration: 0.18, ease: EASE_OUT }}
className="flex items-center gap-3"
>
<div className="flex flex-1 gap-1.5" aria-hidden>
{[0, 1, 2, 3].map((index) => (
<span
key={index}
className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted-foreground/20"
>
{/* scaleX, not width — transform only, off layout. */}
<motion.span
initial={false}
animate={{ scaleX: index < strength ? 1 : 0 }}
transition={
reduce
? { duration: 0 }
: { ...SPRING_LAYOUT, delay: index * 0.03 }
}
className={cn(
"block h-full w-full origin-left rounded-full transition-colors duration-300",
STRENGTH_COLORS[strength],
)}
/>
</span>
))}
</div>
<span
aria-live="polite"
className="relative grid min-w-16 justify-items-end overflow-hidden text-xs font-medium"
>
<span className="sr-only">Password strength: </span>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={strength}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.08 } }
: { opacity: 0, y: -8, transition: { duration: 0.1 } }
}
transition={reduce ? { duration: 0.12 } : SPRING_SWAP}
className={STRENGTH_TEXT[strength]}
>
{STRENGTH_LABELS[strength]}
</motion.span>
</AnimatePresence>
</span>
</motion.div>
) : null}
</AnimatePresence>
{rules.length > 0 ? (
<ul className="grid gap-1.5 sm:grid-cols-2" aria-label="Password requirements">
{rules.map((rule) => {
const met = rule.test(value);
return (
<li
key={rule.id}
className={cn(
"flex items-center gap-2 text-xs transition-colors duration-200",
met ? "text-foreground" : "text-muted-foreground",
)}
>
<RequirementMark met={met} reduce={Boolean(reduce)} />
<span>{rule.label}</span>
<span className="sr-only">{met ? "(met)" : "(not met)"}</span>
</li>
);
})}
</ul>
) : null}
</div>
</div>
);
},
);
/** Eye that morphs to eye-off: a slash draws across and the pupil dims. */
function EyeMorph({ closed, reduce }: { closed: boolean; reduce: boolean }) {
const transition = reduce ? { duration: 0 } : { duration: 0.22, ease: EASE_OUT };
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0" />
<motion.circle
cx={12}
cy={12}
r={3}
initial={false}
animate={{ opacity: closed ? 0.35 : 1 }}
transition={transition}
/>
<motion.path
d="M3 3l18 18"
initial={false}
animate={{ pathLength: closed ? 1 : 0, opacity: closed ? 1 : 0 }}
transition={transition}
/>
</svg>
);
}
function RequirementMark({ met, reduce }: { met: boolean; reduce: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
"grid size-4 shrink-0 place-items-center rounded-full border transition-colors duration-200",
met
? "border-(--color-success) bg-(--color-success) text-background"
: "border-muted-foreground/40 bg-transparent text-transparent",
)}
>
<svg viewBox="0 0 24 24" fill="none" className="size-3" aria-hidden="true">
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: met ? 1 : 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.25, ease: EASE_OUT }}
/>
</svg>
</span>
);
}
function CapsIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
aria-hidden="true"
>
<path d="M12 4 4 12h4v4h8v-4h4z" />
<path d="M8 20h8" />
</svg>
);
}
"use client";
import {
AnimatePresence,
animate,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
useEffect,
useId,
useRef,
useState,
type InputHTMLAttributes,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
export type InputClassNames = {
root?: string;
label?: string;
field?: string;
input?: string;
leftIcon?: string;
rightIcon?: string;
successIcon?: string;
errorMessage?: string;
};
export interface InputProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"value" | "defaultValue" | "onChange"
> {
label?: string;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
/** Truthy error triggers a shake, red border and (if a string) a message. */
error?: string | boolean;
success?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
className?: string;
classNames?: InputClassNames;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
label,
value: valueProp,
defaultValue,
onChange,
onFocus,
onBlur,
error,
success,
leftIcon,
rightIcon,
className,
classNames,
disabled,
id: idProp,
type,
...rest
},
ref,
) {
const reactId = useId();
const id = idProp ?? reactId;
const reduce = useReducedMotion();
const controlled = valueProp !== undefined;
const [internal, setInternal] = useState(defaultValue ?? "");
const value = controlled ? (valueProp ?? "") : internal;
const [focused, setFocused] = useState(false);
const fieldRef = useRef<HTMLDivElement>(null);
const hasError = Boolean(error);
const errorMessage = typeof error === "string" ? error : null;
// Right edge shows the success check, otherwise the caller's right icon.
const rightSlot = success ? null : rightIcon;
// Shake the field when an error appears.
useEffect(() => {
if (!fieldRef.current || reduce || !hasError) return;
animate(
fieldRef.current,
{ x: [0, -6, 6, -4, 4, -2, 0] },
{ duration: 0.45 },
);
}, [hasError, reduce]);
const handleChange = (next: string) => {
if (!controlled) setInternal(next);
onChange?.(next);
};
return (
<div
className={cn("flex flex-col gap-1.5", className, classNames?.root)}
>
{label ? (
<label
htmlFor={id}
className={cn(
"px-1 text-sm font-medium text-foreground",
classNames?.label,
)}
>
{label}
</label>
) : null}
<div
ref={fieldRef}
data-state={
hasError
? "error"
: success
? "success"
: focused
? "focused"
: "idle"
}
className={cn(
"relative h-11 overflow-hidden rounded-full border transition-colors duration-200",
"border-border",
focused && !hasError && "border-foreground/40 ring-2 ring-ring/40",
hasError && "border-destructive ring-2 ring-destructive/25",
disabled && "opacity-60",
classNames?.field,
)}
>
{leftIcon ? (
<span
className={cn(
"pointer-events-none absolute left-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground [&_svg]:h-4 [&_svg]:w-4",
classNames?.leftIcon,
)}
>
{leftIcon}
</span>
) : null}
<input
ref={ref}
id={id}
type={type}
value={value}
disabled={disabled}
aria-invalid={hasError || undefined}
aria-describedby={errorMessage ? `${id}-error` : undefined}
{...rest}
onChange={(e) => handleChange(e.target.value)}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
className={cn(
"peer h-full w-full bg-transparent text-base leading-6 text-foreground caret-foreground outline-none",
"placeholder:text-muted-foreground/60",
leftIcon ? "pl-10" : "pl-3.5",
rightSlot || success ? "pr-10" : "pr-3.5",
disabled && "cursor-not-allowed",
classNames?.input,
)}
/>
{success ? (
<motion.svg
viewBox="0 0 24 24"
fill="none"
className={cn(
"absolute right-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-(--color-success)",
classNames?.successIcon,
)}
>
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, ease: "easeOut" }}
/>
</motion.svg>
) : rightSlot ? (
<span
className={cn(
"absolute right-0 top-0 flex h-full items-center text-muted-foreground [&_button]:grid [&_button]:size-11 [&_button]:place-items-center [&_svg]:h-4 [&_svg]:w-4",
classNames?.rightIcon,
)}
>
{rightSlot}
</span>
) : null}
</div>
<AnimatePresence initial={false}>
{errorMessage ? (
<motion.p
id={`${id}-error`}
role="alert"
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={{ duration: 0.2 }}
className={cn(
"px-1 text-xs text-destructive",
classNames?.errorMessage,
)}
>
{errorMessage}
</motion.p>
) : null}
</AnimatePresence>
</div>
);
});
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
strengthMeter?booleanShow the segmented strength meter once something is typed. Default true.
truerequirements?boolean | PasswordRequirement[]Requirement checklist under the field. `true` uses `defaultPasswordRequirements(minLength)`; pass an array for custom rules.
falseminLength?numberLength below which the score is 0 ("Too short"). Default 8.
8onStrengthChange?((strength: PasswordStrength, label: string) => void)Fires whenever the 0-4 score changes.
—revealable?booleanAllow revealing the password. Default true.
trueicon?ReactNodeLeft icon. Defaults to a lock; pass `null` for none.
—value?string—defaultValue?string—className?string—onChange?((value: string) => void)—error?string | booleanTruthy error triggers a shake, red border and (if a string) a message.
—label?string—classNames?InputClassNames—Updated