UIForms and Inputsmotion/react

PasswordInput

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

Enter a password
  • 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 props
8

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add password-input

Writes 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.

example.tsx
1import { PasswordInput } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <PasswordInput />6  );7}

Props

PropTypeDefault
value

Controlled value.

stringundefined
defaultValue

Initial value when uncontrolled.

string''
onValueChange

Called with the next value on every edit.

(value: string) => voidundefined
visible

Controlled visibility; true shows the password as plain text.

booleanundefined
defaultVisible

Initial visibility when uncontrolled.

booleanfalse
onVisibleChange

Called when the show/hide toggle is pressed.

(visible: boolean) => voidundefined
label

Visible label and accessible name.

string'Password'
placeholder

Placeholder text.

string'Create a strong password'
minLength

Minimum length used by the length rule and strength score.

number8
showStrength

Show the animated four-segment strength meter and its label.

booleantrue
showRules

Show the rule checklist (length, mixed case, number, symbol).

booleantrue
size

Field height preset.

'sm' | 'md' | 'lg''md'
accentColor

Focus ring and active toggle color.

string'#6366f1'
disabled

Disable the field and toggle.

booleanfalse
name

Native name attribute for form submission.

stringundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/password-input.tsx
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}