Forms

Password Strength Meter

A password field with a live segmented strength bar and a requirements checklist, powered by a pure scoring function.

ReactavailableIntermediate
formpasswordvalidationstrengthsecurity
Author: Vedant @vedant7007
Live React preview
Loading preview…
"use client";

import { useId, useState, type ChangeEvent } from "react";
import { Check, Eye, EyeOff, X } from "lucide-react";
import { cn } from "@/lib/utils";

export type PasswordChecks = {
  length: boolean;
  lowercase: boolean;
  uppercase: boolean;
  number: boolean;
  symbol: boolean;
};

export type PasswordStrength = {
  score: 0 | 1 | 2 | 3 | 4;
  label: string;
  percent: number;
  checks: PasswordChecks;
};

const LABELS = ["Very weak", "Weak", "Fair", "Strong", "Very strong"] as const;

/**
 * Pure, dependency-free password strength scorer.
 *
 * Runs five independent requirement checks, then maps the number that pass to
 * a 0–4 score (each of the four visible strength levels needs one more passing
 * check than the last). `percent` is always `score * 25`, so the bar width and
 * the score never disagree.
 */
export function evaluatePasswordStrength(password: string): PasswordStrength {
  const checks: PasswordChecks = {
    length: password.length >= 8,
    lowercase: /[a-z]/.test(password),
    uppercase: /[A-Z]/.test(password),
    number: /[0-9]/.test(password),
    symbol: /[^A-Za-z0-9]/.test(password),
  };

  if (password.length === 0) {
    return { score: 0, label: LABELS[0], percent: 0, checks };
  }

  const passed = Object.values(checks).filter(Boolean).length;
  const score = Math.min(Math.max(passed - 1, 0), 4) as PasswordStrength["score"];

  return { score, label: LABELS[score], percent: score * 25, checks };
}

const REQUIREMENTS: { key: keyof PasswordChecks; text: string }[] = [
  { key: "length", text: "At least 8 characters" },
  { key: "lowercase", text: "A lowercase letter" },
  { key: "uppercase", text: "An uppercase letter" },
  { key: "number", text: "A number" },
  { key: "symbol", text: "A symbol" },
];

const SEGMENT_COLORS = [
  "bg-red-400",
  "bg-orange-400",
  "bg-yellow-400",
  "bg-lime-400",
  "bg-emerald-400",
] as const;

export type PasswordStrengthMeterProps = {
  label?: string;
  className?: string;
};

export default function PasswordStrengthMeter({
  label = "Password",
  className,
}: PasswordStrengthMeterProps) {
  const [password, setPassword] = useState("");
  const [showPassword, setShowPassword] = useState(false);
  const inputId = useId();

  const { score, label: strengthLabel, checks } = evaluatePasswordStrength(password);
  const hasValue = password.length > 0;

  return (
    <div className={cn("w-full max-w-sm flex flex-col gap-4", className)}>
      <div className="relative">
        <input
          id={inputId}
          type={showPassword ? "text" : "password"}
          value={password}
          onChange={(e: ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
          placeholder={label}
          className={cn(
            "w-full rounded-2xl border border-white/15 bg-white/5 py-3 pl-4 pr-11 text-sm text-white backdrop-blur transition duration-200 placeholder:text-white/40",
            "hover:bg-white/10 hover:border-white/30",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:bg-white/10"
          )}
          aria-describedby={`${inputId}-strength`}
        />
        <button
          type="button"
          onClick={() => setShowPassword((prev) => !prev)}
          className="absolute right-4 top-1/2 z-20 -translate-y-1/2 text-white/40 transition-colors hover:text-white/80 focus-visible:outline-none focus-visible:text-white/80 rounded"
          aria-label={showPassword ? "Hide password" : "Show password"}
        >
          {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
        </button>
      </div>

      <div className="flex flex-col gap-2" id={`${inputId}-strength`} aria-live="polite">
        <div className="flex gap-1.5" role="presentation">
          {[0, 1, 2, 3].map((segment) => (
            <div
              key={segment}
              className={cn(
                "h-1.5 flex-1 rounded-full transition-colors duration-300",
                hasValue && segment < score ? SEGMENT_COLORS[score] : "bg-white/10"
              )}
            />
          ))}
        </div>
        {hasValue && (
          <p className="text-xs font-medium text-white/70">
            Strength: <span className="text-white/90">{strengthLabel}</span>
          </p>
        )}
      </div>

      <ul className="flex flex-col gap-1.5">
        {REQUIREMENTS.map(({ key, text }) => {
          const met = checks[key];
          return (
            <li
              key={key}
              className={cn(
                "flex items-center gap-2 text-xs transition-colors",
                met ? "text-emerald-400/90" : "text-white/45"
              )}
            >
              {met ? <Check size={14} /> : <X size={14} />}
              {text}
            </li>
          );
        })}
      </ul>
    </div>
  );
}