# Password Strength

> A password field with a four-bar strength meter and requirements that tick themselves off as you type.

Category: Inputs · Free · Updated 2026-10-21 · Live demo: https://motionkitui.com/components/password-strength

## When to use it

Sign-up, reset-password and account settings forms, anywhere people choose a password and benefit from live guidance.

## How it works

Each rule is a label and a test. On every keystroke the component counts the rules met and scales that to four bars, which fill on a spring with a short stagger and change colour from rust to teal. Each rule's tick is an SVG path drawn with pathLength as it's met. The eye button swaps the input type and draws a slash across itself. The strength word sits in a polite live region, so it's announced without interrupting typing.

## Installation

```bash
npx shadcn@latest add https://motionkitui.com/r/password-strength.json
```

Or copy `components/motionkit/password-strength.tsx` below. Requires react, motion and Tailwind CSS v4.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | `"Password"` | Visible label. |
| `rules` | `PasswordRule[]` | `defaultRules` | Requirements, each a label and a test function. |
| `onValueChange` | `(value: string, met: number) => void` |  | Called with the password and how many rules it meets. |
| `name` | `string` | `"password"` | Form field name. |
| `autoComplete` | `string` | `"new-password"` | Lets password managers offer a generated password. |

## Accessibility

- **Keyboard: Pass.** A real password input and a show/hide button, both reachable with Tab and with visible focus rings.
- **Screen readers: Pass.** The field is described by its strength and rules; strength changes are announced politely, and each rule says "met" or "not met".
- **Reduced motion: Pass.** With reduced motion on, bars and ticks change instantly instead of filling and drawing.
- **Colour contrast: Pass.** Rule text measures 7.7:1 when unmet and 14.7:1 when met. Strength is shown in words, never colour alone.

## Source

```tsx
"use client";

import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

export type PasswordRule = { label: string; test: (value: string) => boolean };

type PasswordStrengthProps = {
  /** Visible label, also the accessible name. */
  label?: string;
  /** Requirements shown under the field. Each one ticks as it is met. */
  rules?: PasswordRule[];
  /** Called with the password and how many rules it meets. */
  onValueChange?: (value: string, met: number) => void;
  /** Form field name, so it submits with a normal form. */
  name?: string;
  /** "new-password" lets password managers offer to generate one. */
  autoComplete?: string;
  className?: string;
};

export const defaultRules: PasswordRule[] = [
  { label: "At least 10 characters", test: (v) => v.length >= 10 },
  { label: "Upper and lower case letters", test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v) },
  { label: "At least one number", test: (v) => /\d/.test(v) },
  { label: "At least one symbol", test: (v) => /[^A-Za-z0-9]/.test(v) },
];

// four steps, from rust to teal, so the colour tells the story before the word does
const levels = [
  { word: "Too weak", color: "#8C3216" },
  { word: "Weak", color: "#C0603F" },
  { word: "Fair", color: "#C9A26B" },
  { word: "Good", color: "#7D9E9D" },
  { word: "Strong", color: "#5FAEB4" },
];

/** A password field with a four-bar strength meter and requirements that tick themselves off as you type. */
export function PasswordStrength({
  label = "Password",
  rules = defaultRules,
  onValueChange,
  name = "password",
  autoComplete = "new-password",
  className = "",
}: PasswordStrengthProps) {
  const id = useId();
  const reduceMotion = useReducedMotion();
  const [value, setValue] = useState("");
  const [visible, setVisible] = useState(false);
  const passed = rules.map((rule) => rule.test(value));
  const met = passed.filter(Boolean).length;
  // the meter is scaled to four bars whatever the number of rules
  const score = value ? Math.max(1, Math.round((met / rules.length) * 4)) : 0;
  const level = levels[score];

  return (
    <div className={`w-full max-w-sm text-[#F6F1E8] ${className}`}>
      <label htmlFor={id} className="mb-2 block text-sm text-[#DCE3E2]">
        {label}
      </label>
      <div className="relative">
        <input
          id={id}
          name={name}
          type={visible ? "text" : "password"}
          value={value}
          autoComplete={autoComplete}
          aria-describedby={`${id}-strength ${id}-rules`}
          onChange={(event) => {
            setValue(event.target.value);
            onValueChange?.(event.target.value, rules.filter((rule) => rule.test(event.target.value)).length);
          }}
          className="w-full rounded-xl border border-white/10 bg-[#141B1B] py-3 pl-4 pr-12 text-sm outline-none transition-colors placeholder:text-[#5E7271] focus:border-[#5FAEB4]/60"
          placeholder="Choose a password"
        />
        <button
          type="button"
          onClick={() => setVisible((v) => !v)}
          aria-label={visible ? "Hide password" : "Show password"}
          aria-pressed={visible}
          className="absolute right-2 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-lg text-[#94A9A7] transition-colors hover:text-[#F6F1E8] focus-visible:outline-2 focus-visible:outline-[#5FAEB4]"
        >
          <svg aria-hidden viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
            <path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" />
            <circle cx="12" cy="12" r="3" />
            {/* the slash draws across the eye when the password is showing */}
            <motion.path d="M4 4l16 16" initial={false} animate={{ pathLength: visible ? 1 : 0, opacity: visible ? 1 : 0 }} transition={{ duration: reduceMotion ? 0 : 0.25 }} />
          </svg>
        </button>
      </div>

      <div aria-hidden className="mt-3 grid grid-cols-4 gap-1.5">
        {[1, 2, 3, 4].map((bar) => (
          <div key={bar} className="h-1.5 overflow-hidden rounded-full bg-white/10">
            <motion.div
              className="h-full origin-left rounded-full"
              initial={false}
              animate={{ scaleX: bar <= score ? 1 : 0, backgroundColor: level.color }}
              transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 26, delay: (bar - 1) * 0.04 }}
            />
          </div>
        ))}
      </div>
      <p id={`${id}-strength`} aria-live="polite" className="mt-2 h-4 text-xs" style={{ color: value ? level.color : "#5E7271" }}>
        {value ? `Strength: ${level.word}` : ""}
      </p>

      <ul id={`${id}-rules`} className="mt-3 space-y-2 text-sm">
        {rules.map((rule, index) => (
          <li key={rule.label} className={`flex items-center gap-2.5 transition-colors ${passed[index] ? "text-[#DCE3E2]" : "text-[#94A9A7]"}`}>
            <span aria-hidden className="relative flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors" style={{ borderColor: passed[index] ? "#5FAEB4" : "rgb(255 255 255 / 0.2)", backgroundColor: passed[index] ? "#5FAEB4" : "transparent" }}>
              <svg viewBox="0 0 16 16" className="size-2.5" fill="none" stroke="#0C1010" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round">
                <motion.path d="M3.5 8.5l3 3 6-7" initial={false} animate={{ pathLength: passed[index] ? 1 : 0 }} transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }} />
              </svg>
            </span>
            {rule.label}
            <span className="sr-only">{passed[index] ? "(met)" : "(not met)"}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}
```
