A password field with an animated eye toggle, a four-segment strength meter that springs and recolors as you type, and a rule checklist whose check marks draw themselves in as each requirement is met.
Preview
- At least 8 characters (not met)
- Upper and lower case letters (not met)
- At least one number (not met)
- At least one symbol (not met)
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add password-inputWrites components/ui/password-input.tsx and components/styles/password-input.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { PasswordInput } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <PasswordInput />6 );7}Props
Accessibility
Native <input type="password"> labelled by a visible <label>. The show/hide control is a native button with aria-pressed, aria-controls and a state-dependent aria-label (Show password / Hide password). The strength label is an aria-live polite region and, together with the rule list, is referenced by aria-describedby; each rule carries visually hidden '(met)' / '(not met)' text. The meter bars are aria-hidden. Under prefers-reduced-motion the meter and check marks update instantly.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/password-input.module.css";6 7export interface PasswordInputProps {8 /** Controlled value */9 value?: string;10 /** Initial value when uncontrolled */11 defaultValue?: string;12 /** Called with the next value on every edit */13 onValueChange?: (value: string) => void;14 /** Controlled visibility (true shows the plain text) */15 visible?: boolean;16 /** Initial visibility when uncontrolled */17 defaultVisible?: boolean;18 /** Called when the show/hide toggle is pressed */19 onVisibleChange?: (visible: boolean) => void;20 /** Visible label (also the accessible name) */21 label?: string;22 /** Placeholder text */23 placeholder?: string;24 /** Minimum length used by the length rule */25 minLength?: number;26 /** Show the animated strength meter */27 showStrength?: boolean;28 /** Show the rule checklist */29 showRules?: boolean;30 /** Field size preset */31 size?: "sm" | "md" | "lg";32 /** Focus ring and toggle accent color */33 accentColor?: string;34 /** Disable the field */35 disabled?: boolean;36 /** Native name attribute for forms */37 name?: string;38 /** Optional className merged onto the root */39 className?: string;40 /** Optional inline styles merged onto the root */41 style?: React.CSSProperties;42}43 44const LEVELS = [45 { label: "Too weak", color: "#ef4444" },46 { label: "Weak", color: "#f97316" },47 { label: "Fair", color: "#eab308" },48 { label: "Good", color: "#84cc16" },49 { label: "Strong", color: "#22c55e" },50] as const;51 52function getRules(value: string, minLength: number) {53 return [54 { id: "len", text: `At least ${minLength} characters`, met: value.length >= minLength },55 { id: "case", text: "Upper and lower case letters", met: /[a-z]/.test(value) && /[A-Z]/.test(value) },56 { id: "num", text: "At least one number", met: /\d/.test(value) },57 { id: "sym", text: "At least one symbol", met: /[^A-Za-z0-9\s]/.test(value) },58 ];59}60 61export function PasswordInput({62 value: valueProp,63 defaultValue = "",64 onValueChange,65 visible: visibleProp,66 defaultVisible = false,67 onVisibleChange,68 label = "Password",69 placeholder = "Create a strong password",70 minLength = 8,71 showStrength = true,72 showRules = true,73 size = "md",74 accentColor = "#6366f1",75 disabled = false,76 name,77 className = "",78 style,79}: PasswordInputProps) {80 const reduce = useReducedMotion();81 const [inner, setInner] = React.useState(defaultValue);82 const value = valueProp !== undefined ? valueProp : inner;83 const [innerVisible, setInnerVisible] = React.useState(defaultVisible);84 const visible = visibleProp !== undefined ? visibleProp : innerVisible;85 const id = React.useId();86 const rules = getRules(value, Math.max(1, minLength));87 const metCount = rules.filter((r) => r.met).length;88 const score = value.length === 0 ? 0 : Math.min(4, metCount + (value.length >= minLength + 4 && metCount >= 3 ? 1 : 0));89 const level = LEVELS[score];90 const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 320, damping: 30 };91 92 const toggleVisible = () => {93 const next = !visible;94 if (visibleProp === undefined) setInnerVisible(next);95 onVisibleChange?.(next);96 };97 98 const describedBy = [showStrength ? `${id}-strength` : "", showRules ? `${id}-rules` : ""].filter(Boolean).join(" ") || undefined;99 100 return (101 <div102 className={`${styles.root} ${className}`.trim()}103 data-size={size}104 data-disabled={disabled ? "" : undefined}105 style={{ "--mf-pw-accent": accentColor, "--mf-pw-level": level.color, ...style } as React.CSSProperties}106 >107 <label htmlFor={id} className={styles.label}>108 {label}109 </label>110 <div className={styles.field}>111 <input112 id={id}113 name={name}114 type={visible ? "text" : "password"}115 value={value}116 placeholder={placeholder}117 disabled={disabled}118 autoComplete="new-password"119 spellCheck={false}120 aria-describedby={describedBy}121 className={styles.input}122 onChange={(e) => {123 if (valueProp === undefined) setInner(e.target.value);124 onValueChange?.(e.target.value);125 }}126 />127 <button128 type="button"129 className={styles.toggle}130 aria-label={visible ? "Hide password" : "Show password"}131 aria-pressed={visible}132 aria-controls={id}133 disabled={disabled}134 onClick={toggleVisible}135 >136 <svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">137 <path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" />138 <motion.circle cx="12" cy="12" r="3" initial={false} animate={{ scale: visible ? 1 : 0.6, opacity: visible ? 1 : 0.5 }} transition={spring} />139 <motion.path d="M4 4l16 16" initial={false} animate={{ pathLength: visible ? 0 : 1, opacity: visible ? 0 : 1 }} transition={reduce ? { duration: 0 } : { duration: 0.22, ease: "easeOut" }} />140 </svg>141 </button>142 </div>143 {showStrength && (144 <div className={styles.strength}>145 <div className={styles.meter} aria-hidden="true">146 {[0, 1, 2, 3].map((i) => (147 <span key={i} className={styles.segment}>148 <motion.span className={styles.fill} initial={false} animate={{ scaleX: score > i ? 1 : 0 }} transition={{ ...spring, delay: reduce ? 0 : i * 0.04 }} />149 </span>150 ))}151 </div>152 <span id={`${id}-strength`} className={styles.levelText} aria-live="polite">153 {value.length === 0 ? "Enter a password" : level.label}154 </span>155 </div>156 )}157 {showRules && (158 <ul id={`${id}-rules`} className={styles.rules}>159 {rules.map((r) => (160 <li key={r.id} className={styles.rule} data-met={r.met ? "" : undefined}>161 <span className={styles.check} aria-hidden="true">162 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">163 <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={false} animate={{ pathLength: r.met ? 1 : 0 }} transition={reduce ? { duration: 0 } : { duration: 0.25, ease: "easeOut" }} />164 </svg>165 </span>166 {r.text}167 <span className={styles.srOnly}>{r.met ? " (met)" : " (not met)"}</span>168 </li>169 ))}170 </ul>171 )}172 </div>173 );174}