Password Strength

Live strength meter with a rule checklist and show/hide toggle.

Start typing a password

  • At least 8 characters
  • An uppercase letter
  • A number
  • A symbol

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/password-strength.tsx

    components/ui/password-strength.tsx
    "use client";
    
    import { motion } from "motion/react";
    import { Check, Eye, EyeOff, X } from "lucide-react";
    import { useId, useState } from "react";
    
    const rules = [
      { label: "At least 8 characters", test: (p: string) => p.length >= 8 },
      { label: "An uppercase letter", test: (p: string) => /[A-Z]/.test(p) },
      { label: "A number", test: (p: string) => /\d/.test(p) },
      { label: "A symbol", test: (p: string) => /[^A-Za-z0-9]/.test(p) },
    ];
    
    const levels = [
      { label: "Too weak", color: "#e11d48" },
      { label: "Weak", color: "#f97316" },
      { label: "Fair", color: "#eab308" },
      { label: "Good", color: "#22c55e" },
      { label: "Strong", color: "#16a34a" },
    ];
    
    export function PasswordStrength({ value, onChange }: { value: string; onChange: (v: string) => void }) {
      const [show, setShow] = useState(false);
      const id = useId();
      const score = rules.filter((r) => r.test(value)).length;
      const level = levels[score];
    
      return (
        <div className="w-full">
          <label htmlFor={id} className="text-sm font-medium text-zinc-900">
            Password
          </label>
          <div className="relative mt-1.5">
            <input
              id={id}
              type={show ? "text" : "password"}
              value={value}
              onChange={(e) => onChange(e.target.value)}
              autoComplete="new-password"
              className="w-full rounded-xl border border-zinc-200 px-4 py-3 pr-11 text-sm outline-none transition focus:border-blue-600 focus:ring-4 focus:ring-blue-600/10"
            />
            <button type="button" onClick={() => setShow((s) => !s)} aria-label={show ? "Hide password" : "Show password"} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-700">
              {show ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
            </button>
          </div>
          <div className="mt-3 flex gap-1.5" aria-hidden>
            {rules.map((_, i) => (
              <div key={i} className="h-1.5 flex-1 overflow-hidden rounded-full bg-zinc-100">
                <motion.div className="h-full rounded-full" animate={{ width: i < score ? "100%" : "0%", backgroundColor: level.color }} transition={{ duration: 0.3 }} />
              </div>
            ))}
          </div>
          <p className="mt-2 text-xs font-medium" style={{ color: value ? level.color : "#a1a1aa" }} aria-live="polite">
            {value ? level.label : "Start typing a password"}
          </p>
          <ul className="mt-3 space-y-1.5">
            {rules.map((r) => {
              const ok = r.test(value);
              return (
                <li key={r.label} className={`flex items-center gap-2 text-xs transition-colors ${ok ? "text-emerald-600" : "text-zinc-500"}`}>
                  {ok ? <Check className="size-3.5" /> : <X className="size-3.5 text-zinc-300" />} {r.label}
                </li>
              );
            })}
          </ul>
        </div>
      );
    }
    
    export default function Demo() {
      const [pw, setPw] = useState("");
      return (
        <div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-6">
          <PasswordStrength value={pw} onChange={setPw} />
        </div>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.