Web InnoventixFreeCode

Password Strength Form

Original · free

password field with a strength meter

byWeb InnoventixReact + Tailwind
formpasswordstrengthforms
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/form-password-strength.json
form-password-strength.tsx
"use client";

import {
  useCallback,
  useId,
  useMemo,
  useRef,
  useState,
  type ChangeEvent,
  type FormEvent,
  type KeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type Requirement = {
  id: string;
  label: string;
  test: (value: string) => boolean;
};

const REQUIREMENTS: Requirement[] = [
  { id: "length", label: "At least 12 characters", test: (v) => v.length >= 12 },
  { id: "lower", label: "A lowercase letter (a–z)", test: (v) => /[a-z]/.test(v) },
  { id: "upper", label: "An uppercase letter (A–Z)", test: (v) => /[A-Z]/.test(v) },
  { id: "number", label: "A number (0–9)", test: (v) => /\d/.test(v) },
  { id: "symbol", label: "A symbol (!@#$%…)", test: (v) => /[^A-Za-z0-9]/.test(v) },
];

type Tier = {
  label: string;
  bar: string;
  text: string;
  ring: string;
};

// Index 0 = nothing entered / too weak to score.
const TIERS: Tier[] = [
  { label: "No password yet", bar: "", text: "text-slate-400 dark:text-slate-500", ring: "" },
  { label: "Weak", bar: "bg-rose-500", text: "text-rose-600 dark:text-rose-400", ring: "ring-rose-500/40" },
  { label: "Fair", bar: "bg-amber-500", text: "text-amber-600 dark:text-amber-400", ring: "ring-amber-500/40" },
  { label: "Good", bar: "bg-sky-500", text: "text-sky-600 dark:text-sky-400", ring: "ring-sky-500/40" },
  { label: "Strong", bar: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400", ring: "ring-emerald-500/40" },
];

const COMMON = new Set<string>([
  "password",
  "password1",
  "passw0rd",
  "123456",
  "12345678",
  "123456789",
  "qwerty",
  "qwerty123",
  "abc123",
  "111111",
  "000000",
  "letmein",
  "welcome",
  "admin",
  "iloveyou",
  "monkey",
  "dragon",
  "football",
  "sunshine",
]);

function looksPredictable(value: string): boolean {
  const lower = value.toLowerCase();
  if (COMMON.has(lower)) return true;
  if (/(.)\1{3,}/.test(value)) return true; // 4+ repeated characters
  if (/(?:0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef|qwer|asdf)/i.test(value)) return true;
  return false;
}

function randomInt(max: number): number {
  if (typeof window !== "undefined" && window.crypto?.getRandomValues) {
    const arr = new Uint32Array(1);
    window.crypto.getRandomValues(arr);
    return (arr[0] ?? 0) % max;
  }
  return Math.floor(Math.random() * max);
}

function generatePassword(): string {
  // Ambiguous glyphs (0/O, 1/l/I) are omitted so the result is easy to read aloud.
  const sets = [
    "abcdefghijkmnpqrstuvwxyz",
    "ABCDEFGHJKLMNPQRSTUVWXYZ",
    "23456789",
    "!@#$%^&*()-_=+",
  ];
  const all = sets.join("");
  const target = 18;
  const chars: string[] = [];
  for (const set of sets) chars.push(set[randomInt(set.length)] ?? "");
  while (chars.length < target) chars.push(all[randomInt(all.length)] ?? "");
  for (let i = chars.length - 1; i > 0; i -= 1) {
    const j = randomInt(i + 1);
    const tmp = chars[i]!;
    chars[i] = chars[j]!;
    chars[j] = tmp;
  }
  return chars.join("");
}

function EyeIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true" className="h-5 w-5">
      <path strokeLinecap="round" strokeLinejoin="round" d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
      <circle cx="12" cy="12" r="3" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function EyeOffIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true" className="h-5 w-5">
      <path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
      <path strokeLinecap="round" strokeLinejoin="round" d="M10.6 6.2A9.7 9.7 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.6 3.2M6.3 7.9A17 17 0 0 0 2.5 12S6 18 12 18a9.4 9.4 0 0 0 3.3-.6" />
      <path strokeLinecap="round" strokeLinejoin="round" d="M9.9 10.1a3 3 0 0 0 4.2 4.2" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={2.4} aria-hidden="true" className="h-3.5 w-3.5">
      <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 10.5l3.2 3.2 7.8-8" />
    </svg>
  );
}

function DotIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={2.4} aria-hidden="true" className="h-3.5 w-3.5">
      <path strokeLinecap="round" d="M6 10h8" />
    </svg>
  );
}

function ShieldIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true" className="h-5 w-5">
      <path strokeLinecap="round" strokeLinejoin="round" d="M12 3l7 2.5v5.2c0 4.5-3 8.1-7 9.3-4-1.2-7-4.8-7-9.3V5.5L12 3Z" />
      <path strokeLinecap="round" strokeLinejoin="round" d="M8.8 12.2l2.1 2.1 4.3-4.5" />
    </svg>
  );
}

function RefreshIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden="true" className="h-4 w-4">
      <path strokeLinecap="round" strokeLinejoin="round" d="M15.5 8A6 6 0 0 0 5 6.2M4.5 12A6 6 0 0 0 15 13.8" />
      <path strokeLinecap="round" strokeLinejoin="round" d="M15.5 3.5V8h-4.5M4.5 16.5V12h4.5" />
    </svg>
  );
}

function CopyIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden="true" className="h-4 w-4">
      <rect x="7" y="7" width="9" height="9" rx="2" strokeLinejoin="round" />
      <path strokeLinecap="round" strokeLinejoin="round" d="M4 13V5a1 1 0 0 1 1-1h8" />
    </svg>
  );
}

export default function FormPasswordStrength() {
  const uid = useId();
  const reduce = useReducedMotion();

  const pwId = `${uid}-pw`;
  const confirmId = `${uid}-confirm`;
  const meterId = `${uid}-meter`;
  const reqId = `${uid}-req`;

  const [password, setPassword] = useState("");
  const [confirm, setConfirm] = useState("");
  const [revealPw, setRevealPw] = useState(false);
  const [revealConfirm, setRevealConfirm] = useState(false);
  const [capsLock, setCapsLock] = useState(false);
  const [copied, setCopied] = useState(false);
  const [submitted, setSubmitted] = useState(false);

  const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const met = useMemo(
    () => REQUIREMENTS.map((r) => r.test(password)),
    [password],
  );
  const metCount = met.filter(Boolean).length;

  const level = useMemo<number>(() => {
    if (password.length === 0) return 0;
    if (looksPredictable(password)) return 1;
    if (password.length < 8) return 1;
    if (metCount <= 2) return 1;
    if (metCount === 3) return 2;
    if (metCount === 4) return 3;
    return 4;
  }, [password, metCount]);

  const tier = TIERS[level] ?? TIERS[0]!;
  const predictable = password.length > 0 && looksPredictable(password);

  const mismatch = confirm.length > 0 && confirm !== password;
  const matched = confirm.length > 0 && confirm === password;
  const canSubmit = level === 4 && matched;

  const handlePwCaps = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
    setCapsLock(e.getModifierState("CapsLock"));
  }, []);

  const handleGenerate = useCallback(() => {
    const next = generatePassword();
    setPassword(next);
    setConfirm("");
    setRevealPw(true);
    setSubmitted(false);
  }, []);

  const handleCopy = useCallback(() => {
    if (!password) return;
    const done = () => {
      setCopied(true);
      if (copyTimer.current) clearTimeout(copyTimer.current);
      copyTimer.current = setTimeout(() => setCopied(false), 1800);
    };
    if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(password).then(done).catch(() => undefined);
    }
  }, [password]);

  const handleSubmit = useCallback(
    (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      if (!canSubmit) return;
      setSubmitted(true);
    },
    [canSubmit],
  );

  const handleReset = useCallback(() => {
    setPassword("");
    setConfirm("");
    setRevealPw(false);
    setRevealConfirm(false);
    setCapsLock(false);
    setSubmitted(false);
  }, []);

  const meterStatus =
    password.length === 0
      ? "Password strength: not set"
      : `Password strength: ${tier.label}`;

  const inputBase =
    "block w-full rounded-xl border bg-white px-4 py-3 pr-12 text-[15px] text-slate-900 shadow-sm outline-none transition placeholder:text-slate-400 focus-visible:ring-4 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500";

  const iconBtn =
    "absolute right-2 top-1/2 grid h-9 w-9 -translate-y-1/2 place-items-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100";

  const pillBtn =
    "inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 shadow-sm transition hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white dark:focus-visible:ring-offset-slate-900";

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 px-4 py-16 sm:py-24 dark:from-slate-950 dark:to-slate-900">
      <style>{`
        @keyframes fps-pop {
          0% { transform: scale(0.3); opacity: 0; }
          60% { transform: scale(1.18); }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes fps-glow {
          0%, 100% { opacity: 0.4; transform: scale(0.8); }
          50% { opacity: 1; transform: scale(1); }
        }
        @keyframes fps-shake {
          0%, 100% { transform: translateX(0); }
          20% { transform: translateX(-4px); }
          40% { transform: translateX(4px); }
          60% { transform: translateX(-3px); }
          80% { transform: translateX(3px); }
        }
        @keyframes fps-rise {
          from { opacity: 0; transform: translateY(6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .fps-pop { animation: fps-pop 0.28s ease-out both; }
        .fps-glow { animation: fps-glow 1.8s ease-in-out infinite; }
        .fps-shake { animation: fps-shake 0.4s ease-in-out both; }
        .fps-rise { animation: fps-rise 0.4s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .fps-pop, .fps-glow, .fps-shake, .fps-rise { animation: none !important; }
        }
      `}</style>

      {/* soft decorative aura */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute left-1/2 top-0 -z-0 h-72 w-[36rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15"
      />

      <div className="relative z-10 mx-auto w-full max-w-md">
        <div className="rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-900/5 backdrop-blur sm:p-8 dark:border-slate-800 dark:bg-slate-900/80 dark:shadow-black/30">
          <header className="mb-6 flex items-start gap-3">
            <span className="mt-0.5 grid h-10 w-10 flex-none place-items-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/30">
              <ShieldIcon />
            </span>
            <div>
              <h2 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white">
                Set your password
              </h2>
              <p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
                Pick something you don&rsquo;t use anywhere else. We&rsquo;ll never ask for it by email or phone.
              </p>
            </div>
          </header>

          {submitted ? (
            <div className="fps-rise rounded-2xl border border-emerald-200 bg-emerald-50 p-6 text-center dark:border-emerald-900/60 dark:bg-emerald-950/40">
              <span className="mx-auto grid h-12 w-12 place-items-center rounded-full bg-emerald-600 text-white">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} aria-hidden="true" className="h-6 w-6">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 12.5l4 4 10-10.5" />
                </svg>
              </span>
              <h3 className="mt-4 text-lg font-semibold text-emerald-900 dark:text-emerald-200">
                Password saved
              </h3>
              <p className="mt-1 text-sm text-emerald-700 dark:text-emerald-300/80">
                Your new password clears our security bar. You can sign in with it right away.
              </p>
              <button
                type="button"
                onClick={handleReset}
                className="mt-5 inline-flex items-center justify-center rounded-xl border border-emerald-300 bg-white px-4 py-2 text-sm font-semibold text-emerald-800 transition hover:bg-emerald-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-emerald-800 dark:bg-slate-900 dark:text-emerald-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
              >
                Choose a different one
              </button>
            </div>
          ) : (
            <form className="flex flex-col gap-5" onSubmit={handleSubmit} noValidate>
              {/* Password field */}
              <div>
                <div className="mb-2 flex items-center justify-between gap-2">
                  <label htmlFor={pwId} className="text-sm font-semibold text-slate-800 dark:text-slate-200">
                    New password
                  </label>
                  <div className="flex items-center gap-2">
                    <button type="button" onClick={handleGenerate} className={pillBtn}>
                      <RefreshIcon />
                      Generate
                    </button>
                    <button
                      type="button"
                      onClick={handleCopy}
                      disabled={password.length === 0}
                      className={pillBtn}
                      aria-live="polite"
                    >
                      <CopyIcon />
                      {copied ? "Copied" : "Copy"}
                    </button>
                  </div>
                </div>

                <div className="relative">
                  <input
                    id={pwId}
                    name="new-password"
                    type={revealPw ? "text" : "password"}
                    value={password}
                    autoComplete="new-password"
                    spellCheck={false}
                    placeholder="Type a strong password"
                    aria-describedby={`${meterId} ${reqId}`}
                    onChange={(e: ChangeEvent<HTMLInputElement>) => {
                      setPassword(e.target.value);
                      setSubmitted(false);
                    }}
                    onKeyUp={handlePwCaps}
                    onKeyDown={handlePwCaps}
                    onBlur={() => setCapsLock(false)}
                    className={`${inputBase} ${
                      predictable
                        ? "border-rose-300 focus-visible:border-rose-400 focus-visible:ring-rose-500/25 dark:border-rose-800"
                        : "border-slate-300 focus-visible:border-indigo-400 focus-visible:ring-indigo-500/25 dark:border-slate-700"
                    }`}
                  />
                  <button
                    type="button"
                    onClick={() => setRevealPw((v) => !v)}
                    aria-pressed={revealPw}
                    aria-label={revealPw ? "Hide password" : "Show password"}
                    className={iconBtn}
                  >
                    {revealPw ? <EyeOffIcon /> : <EyeIcon />}
                  </button>
                </div>

                {capsLock && (
                  <p className="fps-rise mt-2 flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400">
                    <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
                      <path d="M10 2 3 9h3.5v5h7V9H17L10 2Z" />
                    </svg>
                    Caps Lock is on
                  </p>
                )}

                {/* Strength meter */}
                <div id={meterId} role="status" aria-live="polite" className="mt-3">
                  <div className="flex gap-1.5" aria-hidden="true">
                    {[0, 1, 2, 3].map((i) => (
                      <div
                        key={i}
                        className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
                      >
                        <motion.div
                          className={`h-full origin-left rounded-full ${i < level ? tier.bar : ""}`}
                          initial={false}
                          animate={{ scaleX: i < level ? 1 : 0 }}
                          transition={
                            reduce
                              ? { duration: 0 }
                              : { type: "spring", stiffness: 260, damping: 26, delay: i * 0.04 }
                          }
                          style={{ width: "100%" }}
                        />
                      </div>
                    ))}
                  </div>
                  <div className="mt-2 flex items-center justify-between text-xs">
                    <span className={`flex items-center gap-1.5 font-semibold ${tier.text}`}>
                      {level === 4 && !reduce && (
                        <span className="fps-glow inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
                      )}
                      {tier.label}
                    </span>
                    <span className="text-slate-400 dark:text-slate-500">
                      {metCount}/5 checks
                    </span>
                  </div>
                  <span className="sr-only">{meterStatus}</span>
                  {predictable && (
                    <p className="mt-1.5 text-xs text-rose-600 dark:text-rose-400">
                      That looks like a commonly guessed password. Try something less predictable.
                    </p>
                  )}
                </div>

                {/* Requirements checklist */}
                <ul id={reqId} className="mt-4 grid grid-cols-1 gap-1.5 sm:grid-cols-2">
                  {REQUIREMENTS.map((req, i) => {
                    const ok = met[i] ?? false;
                    return (
                      <li key={req.id} className="flex items-center gap-2 text-sm">
                        <span
                          className={`grid h-4 w-4 flex-none place-items-center rounded-full transition-colors ${
                            ok
                              ? "bg-emerald-500 text-white"
                              : "bg-slate-200 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
                          }`}
                        >
                          <span key={ok ? "ok" : "no"} className={ok ? "fps-pop" : undefined}>
                            {ok ? <CheckIcon /> : <DotIcon />}
                          </span>
                        </span>
                        <span
                          className={
                            ok
                              ? "text-slate-700 dark:text-slate-300"
                              : "text-slate-500 dark:text-slate-400"
                          }
                        >
                          {req.label}
                        </span>
                        <span className="sr-only">{ok ? " met" : " not met"}</span>
                      </li>
                    );
                  })}
                </ul>
              </div>

              {/* Confirm field */}
              <div>
                <label htmlFor={confirmId} className="mb-2 block text-sm font-semibold text-slate-800 dark:text-slate-200">
                  Confirm password
                </label>
                <div className="relative">
                  <input
                    id={confirmId}
                    name="confirm-password"
                    type={revealConfirm ? "text" : "password"}
                    value={confirm}
                    autoComplete="new-password"
                    spellCheck={false}
                    placeholder="Re-enter your password"
                    aria-invalid={mismatch}
                    aria-describedby={mismatch ? `${confirmId}-err` : undefined}
                    onChange={(e: ChangeEvent<HTMLInputElement>) => {
                      setConfirm(e.target.value);
                      setSubmitted(false);
                    }}
                    className={`${inputBase} ${
                      mismatch
                        ? "border-rose-300 focus-visible:border-rose-400 focus-visible:ring-rose-500/25 dark:border-rose-800"
                        : matched
                          ? "border-emerald-300 focus-visible:border-emerald-400 focus-visible:ring-emerald-500/25 dark:border-emerald-800"
                          : "border-slate-300 focus-visible:border-indigo-400 focus-visible:ring-indigo-500/25 dark:border-slate-700"
                    }`}
                  />
                  <button
                    type="button"
                    onClick={() => setRevealConfirm((v) => !v)}
                    aria-pressed={revealConfirm}
                    aria-label={revealConfirm ? "Hide confirmation password" : "Show confirmation password"}
                    className={iconBtn}
                  >
                    {revealConfirm ? <EyeOffIcon /> : <EyeIcon />}
                  </button>
                </div>
                {mismatch ? (
                  <p id={`${confirmId}-err`} className="fps-shake mt-2 text-xs font-medium text-rose-600 dark:text-rose-400">
                    Passwords don&rsquo;t match yet.
                  </p>
                ) : matched ? (
                  <p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
                    <CheckIcon />
                    Passwords match.
                  </p>
                ) : null}
              </div>

              <button
                type="submit"
                disabled={!canSubmit}
                className="mt-1 inline-flex items-center justify-center rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-sm shadow-indigo-600/30 transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-slate-300 disabled:text-slate-500 disabled:shadow-none dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-800 dark:disabled:text-slate-500"
              >
                {canSubmit ? "Save password" : "Meet all requirements to continue"}
              </button>

              <p className="flex items-center justify-center gap-1.5 text-center text-xs text-slate-500 dark:text-slate-500">
                <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
                  <path fillRule="evenodd" d="M10 1a4 4 0 0 0-4 4v2H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-1V5a4 4 0 0 0-4-4Zm2 6V5a2 2 0 1 0-4 0v2h4Z" clipRule="evenodd" />
                </svg>
                Hashed with Argon2id before it ever reaches our database.
              </p>
            </form>
          )}
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →