Strength Meter

PreviousNext

A password strength meter component

Docs
rigiduicomponent

Preview

Loading preview…
r/new-york/strength-meter/strength-meter.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { Eye, EyeOff, Check, X, RefreshCw } from "lucide-react"
import { Input } from "@/components/ui/input"

export type StrengthLevel = "empty" | "weak" | "fair" | "good" | "strong"

export interface PasswordStrengthRequirement {
  label: string
  validator: (password: string) => boolean
}

export interface StrengthMeterTheme {
  container?: string
  input?: string
  inputContainer?: string
  meterContainer?: string
  meterSegment?: string
  strengthText?: string
  requirementsContainer?: string
  requirementItem?: string
  requirementIcon?: string
  requirementText?: string
  strengthColors?: {
    empty?: string
    weak?: string
    fair?: string
    good?: string
    strong?: string
  }
}

export interface PasswordStrengthMeterProps
  extends React.HTMLAttributes<HTMLDivElement> {
  value?: string
  onValueChange?: (value: string) => void
  showText?: boolean
  showRequirements?: boolean
  segments?: number
  strengthThresholds?: Record<StrengthLevel, number>
  requirements?: PasswordStrengthRequirement[]
  customCalculateStrength?: (password: string) => number
  showPasswordToggle?: boolean
  strengthLabels?: Record<StrengthLevel, string>
  className?: string
  meterClassName?: string
  inputClassName?: string
  placeholder?: string
  enableAutoGenerate?: boolean
  autoGenerateLength?: number
  theme?: StrengthMeterTheme
}

const defaultRequirements: PasswordStrengthRequirement[] = [
  {
    label: "At least 8 characters",
    validator: (password) => password.length >= 8,
  },
  {
    label: "At least one lowercase letter",
    validator: (password) => /[a-z]/.test(password),
  },
  {
    label: "At least one uppercase letter",
    validator: (password) => /[A-Z]/.test(password),
  },
  {
    label: "At least one number",
    validator: (password) => /\d/.test(password),
  },
  {
    label: "At least one special character",
    validator: (password) => /[!@#$%^&*(),.?":{}|<>]/.test(password),
  },
]

const defaultStrengthLabels = {
  empty: "Empty",
  weak: "Weak",
  fair: "Fair",
  good: "Good",
  strong: "Strong",
}

const defaultStrengthThresholds = {
  empty: 0,
  weak: 1,
  fair: 40,
  good: 70,
  strong: 90,
}

const defaultTheme: StrengthMeterTheme = {
  container: "space-y-3",
  inputContainer: "relative",
  input: "pr-20 border-border focus:border-primary focus:ring-primary/20 bg-background text-foreground",
  meterContainer: "w-full h-2 rounded-full bg-muted flex gap-1 overflow-hidden",
  meterSegment: "h-full rounded-sm transition-all duration-300 ease-in-out flex-1",
  strengthText: "text-sm font-medium",
  requirementsContainer: "space-y-2",
  requirementItem: "flex items-center gap-2 text-sm",
  requirementIcon: "h-4 w-4 shrink-0",
  requirementText: "text-muted-foreground",
  strengthColors: {
    empty: "bg-transparent",
    weak: "bg-red-500 dark:bg-red-600",
    fair: "bg-amber-500 dark:bg-amber-600",
    good: "bg-blue-500 dark:bg-blue-600",
    strong: "bg-emerald-500 dark:bg-emerald-600"
  }
}

export function PasswordStrengthMeter({
  value = "",
  onValueChange,
  showText = true,
  showRequirements = true,
  segments = 4,
  strengthThresholds = defaultStrengthThresholds,
  requirements = defaultRequirements,
  customCalculateStrength,
  showPasswordToggle = true,
  strengthLabels = defaultStrengthLabels,
  className,
  meterClassName,
  inputClassName,
  placeholder = "Enter password",
  enableAutoGenerate = false,
  autoGenerateLength = 10,
  theme,
  ...props
}: PasswordStrengthMeterProps) {
  const [password, setPassword] = React.useState(value)
  const [showPassword, setShowPassword] = React.useState(false)

  const appliedTheme = { ...defaultTheme, ...theme }

  React.useEffect(() => {
    setPassword(value)
  }, [value])

  const generateStrongPassword = (length: number = autoGenerateLength): string => {
    const lowercase = 'abcdefghijklmnopqrstuvwxyz'
    const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
    const numbers = '0123456789'
    const specialChars = '!@#$%^&*(),.?":{}|<>'

    const allChars = lowercase + uppercase + numbers + specialChars

    let password = ''

    password += lowercase[Math.floor(Math.random() * lowercase.length)]
    password += uppercase[Math.floor(Math.random() * uppercase.length)]
    password += numbers[Math.floor(Math.random() * numbers.length)]
    password += specialChars[Math.floor(Math.random() * specialChars.length)]

    for (let i = 4; i < length; i++) {
      password += allChars[Math.floor(Math.random() * allChars.length)]
    }

    return password.split('').sort(() => Math.random() - 0.5).join('')
  }

  const handleGeneratePassword = () => {
    const newPassword = generateStrongPassword(autoGenerateLength)
    setPassword(newPassword)
    onValueChange?.(newPassword)
  }

  const calculateBaseStrength = (password: string): number => {
    if (!password) return 0

    let score = 0
    let passedRequirements = 0

    requirements.forEach((requirement) => {
      if (requirement.validator(password)) {
        passedRequirements++
      }
    })

    score = (passedRequirements / requirements.length) * 100

    if (password.length > 12) score += 10
    if (password.length > 16) score += 10
    if (/[!@#$%^&*(),.?":{}|<>]{2,}/.test(password)) score += 10

    return Math.min(score, 100)
  }

  const calculateStrength = customCalculateStrength || calculateBaseStrength
  const strengthScore = calculateStrength(password)

  const getStrengthLevel = (): StrengthLevel => {
    if (strengthScore >= strengthThresholds.strong) return "strong"
    if (strengthScore >= strengthThresholds.good) return "good"
    if (strengthScore >= strengthThresholds.fair) return "fair"
    if (strengthScore >= strengthThresholds.weak) return "weak"
    return "empty"
  }

  const strengthLevel = getStrengthLevel()

  const getSegmentStrength = (index: number): StrengthLevel => {
    const segmentThreshold = (index + 1) * (100 / segments)

    if (strengthScore >= segmentThreshold) {
      if (strengthLevel === "strong") return "strong"
      if (strengthLevel === "good") return "good"
      if (strengthLevel === "fair") return "fair"
      if (strengthLevel === "weak") return "weak"
    }

    return "empty"
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value
    setPassword(newValue)
    onValueChange?.(newValue)
  }

  const togglePasswordVisibility = () => {
    setShowPassword(!showPassword)
  }

  const getPassedRequirements = (): PasswordStrengthRequirement[] => {
    return requirements.filter((requirement) => requirement.validator(password))
  }

  const getStrengthColor = (): string => {
    if (!appliedTheme.strengthColors) {
      switch (strengthLevel) {
        case "strong": return "text-emerald-500"
        case "good": return "text-blue-500"
        case "fair": return "text-amber-500"
        case "weak": return "text-red-500"
        default: return "text-muted-foreground"
      }
    }

    switch (strengthLevel) {
      case "strong": return "text-emerald-500 dark:text-emerald-400"
      case "good": return "text-blue-500 dark:text-blue-400"
      case "fair": return "text-amber-500 dark:text-amber-400"
      case "weak": return "text-red-500 dark:text-red-400"
      default: return "text-muted-foreground"
    }
  }

  const getSegmentColor = (segmentStrength: StrengthLevel): string => {
    return appliedTheme.strengthColors?.[segmentStrength] || appliedTheme.strengthColors?.empty || "bg-transparent"
  }

  return (
    <div className={cn(appliedTheme.container, className)} {...props}>
      <div className={cn(appliedTheme.inputContainer)}>
        <Input
          type={showPassword ? "text" : "password"}
          value={password}
          onChange={handleChange}
          className={cn(appliedTheme.input, inputClassName)}
          placeholder={placeholder}
        />
        <div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
          {enableAutoGenerate && (
            <button
              type="button"
              onClick={handleGeneratePassword}
              aria-label="Generate strong password"
              title="Generate strong password"
            >
              <RefreshCw className="h-4 w-4" />
            </button>
          )}
          {showPasswordToggle && (
            <button
              type="button"
              onClick={togglePasswordVisibility}
              aria-label={showPassword ? "Hide password" : "Show password"}
            >
              {showPassword ? (
                <EyeOff className="h-4 w-4" />
              ) : (
                <Eye className="h-4 w-4" />
              )}
            </button>
          )}
        </div>
      </div>

      <div className={cn(appliedTheme.meterContainer, meterClassName)}>
        {Array.from({ length: segments }).map((_, i) => (
          <div
            key={i}
            className={cn(
              appliedTheme.meterSegment,
              getSegmentColor(getSegmentStrength(i))
            )}
            style={{
              transitionDelay: `${i * 75}ms`,
            }}
          />
        ))}
      </div>

      {showText && password && (
        <div className="flex items-center">
          <span className={cn(appliedTheme.strengthText, getStrengthColor())}>
            {strengthLabels[strengthLevel]}
          </span>
          <span className="ml-auto text-xs text-muted-foreground">
            {getPassedRequirements().length} of {requirements.length} requirements met
          </span>
        </div>
      )}

      {showRequirements && (
        <div className={cn(appliedTheme.requirementsContainer)}>
          {enableAutoGenerate && (
            <div className="flex items-center justify-between p-3 bg-blue-50 dark:bg-blue-950/30 rounded-lg border border-blue-200 dark:border-blue-800 gap-4">
              <div className="flex items-center gap-2">
                <RefreshCw className="h-4 w-4 text-blue-600 dark:text-blue-400" />
                <span className="text-sm font-medium text-blue-800 dark:text-blue-200">
                  Auto-generate strong password
                </span>
              </div>
              <button
                type="button"
                onClick={handleGeneratePassword}
                className="px-3 py-1 text-xs font-medium text-blue-700 bg-blue-100 hover:bg-blue-200 dark:text-blue-300 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-md transition-colors"
              >
                Generate
              </button>
            </div>
          )}
          <ul className="space-y-2">
            {requirements.map((requirement, index) => {
              const passed = requirement.validator(password)
              return (
                <li
                  key={index}
                  className={cn(appliedTheme.requirementItem)}
                >
                  {passed ? (
                    <Check className={cn(appliedTheme.requirementIcon, "text-emerald-500 dark:text-emerald-400")} />
                  ) : (
                    <X className={cn(appliedTheme.requirementIcon, "text-muted-foreground")} />
                  )}
                  <span className={cn(
                    appliedTheme.requirementText,
                    passed ? "text-foreground" : "text-muted-foreground"
                  )}>
                    {requirement.label}
                  </span>
                </li>
              )
            })}
          </ul>
        </div>
      )}
    </div>
  )
}

Installation

npx shadcn@latest add @rigidui/strength-meter

Usage

import { StrengthMeter } from "@/components/strength-meter"
<StrengthMeter />