Inputs & Forms

Password Input

A password field with an icon-swap visibility toggle and an optional strength meter whose four segments fill with staggered delays as the label crossfades.

Install

npx shadcn@latest add @paragon/password-input

password-input.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";

export interface PasswordInputProps
  extends Omit<React.ComponentProps<"input">, "type"> {
  /** Visible label, wired via htmlFor. */
  label?: string;
  /** Helper text below the field. */
  hint?: string;
  /** Error message. Turns the field red. */
  error?: string;
  /** Show the 4-segment strength meter below the field. */
  showStrength?: boolean;
}

const STRENGTH = [
  { label: "", color: "" },
  { label: "Weak", color: "bg-destructive" },
  { label: "Fair", color: "bg-amber-500" },
  { label: "Good", color: "bg-emerald-500" },
  { label: "Strong", color: "bg-emerald-500" },
] as const;

const STRENGTH_TEXT = [
  "text-muted-foreground",
  "text-destructive",
  "text-amber-600 dark:text-amber-400",
  "text-emerald-600 dark:text-emerald-400",
  "text-emerald-600 dark:text-emerald-400",
] as const;

function scorePassword(password: string): number {
  if (!password) return 0;
  // Anything under 8 characters is weak no matter its alphabet.
  if (password.length < 8) return 1;
  let score = 1;
  if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score += 1;
  if (/\d/.test(password)) score += 1;
  if (/[^A-Za-z0-9]/.test(password)) score += 1;
  // "Strong" additionally requires 12+ characters.
  if (password.length < 12) score = Math.min(score, 3);
  return Math.min(score, 4);
}

/**
 * Password field with an icon-swap visibility toggle and an optional
 * strength meter: four segments fill with 50ms staggered delays, the color
 * steps destructive → amber → green, and the strength label crossfades.
 */
export function PasswordInput({
  label,
  hint,
  error,
  showStrength = false,
  className,
  id: idProp,
  value,
  defaultValue,
  onChange,
  disabled,
  "aria-describedby": ariaDescribedBy,
  ...props
}: PasswordInputProps) {
  const autoId = React.useId();
  const id = idProp ?? autoId;
  const messageId = `${id}-message`;

  const reducedMotion = useReducedMotion();
  const [visible, setVisible] = React.useState(false);
  const [internal, setInternal] = React.useState(String(defaultValue ?? ""));
  const current = value !== undefined ? String(value) : internal;
  const score = scorePassword(current);
  const invalid = Boolean(error);
  const message = error ?? hint;

  return (
    <div className="w-full">
      {label && (
        <label
          htmlFor={id}
          className="mb-1.5 block text-sm font-medium text-foreground"
        >
          {label}
        </label>
      )}
      <div className="relative">
        <input
          id={id}
          type={visible ? "text" : "password"}
          value={value}
          defaultValue={value === undefined ? defaultValue : undefined}
          disabled={disabled}
          aria-invalid={invalid || undefined}
          aria-describedby={
            cn(message ? messageId : undefined, ariaDescribedBy) || undefined
          }
          onChange={(event) => {
            if (value === undefined) setInternal(event.target.value);
            onChange?.(event);
          }}
          className={cn(
            "h-9 w-full min-w-0 rounded-lg border border-input bg-transparent pr-10 pl-3 text-sm text-foreground",
            "transition-[border-color,box-shadow] duration-150 ease-out",
            "placeholder:text-muted-foreground",
            "outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
            "disabled:cursor-not-allowed disabled:opacity-50",
            invalid &&
              "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
            className,
          )}
          {...props}
        />
        <button
          type="button"
          aria-label={visible ? "Hide password" : "Show password"}
          aria-pressed={visible}
          disabled={disabled}
          onClick={() => setVisible((v) => !v)}
          className="absolute top-1/2 right-1.5 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:text-foreground disabled:pointer-events-none disabled:opacity-50 after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2"
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={visible ? "hide" : "show"}
              initial={
                reducedMotion
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
              }
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={
                reducedMotion
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
              }
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
              className="flex"
            >
              {visible ? (
                <EyeOff aria-hidden className="size-4" />
              ) : (
                <Eye aria-hidden className="size-4" />
              )}
            </motion.span>
          </AnimatePresence>
        </button>
      </div>

      {showStrength && (
        <div className="mt-2 flex items-center gap-2">
          <div aria-hidden className="flex grow gap-1">
            {Array.from({ length: 4 }, (_, index) => {
              const active = index < score;
              return (
                <span
                  key={index}
                  className={cn(
                    "h-1 flex-1 rounded-full transition-[background-color] duration-200 ease-out",
                    active ? STRENGTH[score].color : "bg-secondary",
                  )}
                  // Stagger fills left-to-right; empty immediately together.
                  style={{ transitionDelay: active ? `${index * 50}ms` : "0ms" }}
                />
              );
            })}
          </div>
          <span
            role="status"
            className={cn(
              "relative flex h-4 w-12 items-center justify-end text-xs font-medium",
              STRENGTH_TEXT[score],
            )}
          >
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={STRENGTH[score].label || "empty"}
                initial={{ opacity: 0, filter: "blur(2px)" }}
                animate={{ opacity: 1, filter: "blur(0px)" }}
                exit={{ opacity: 0, filter: "blur(2px)" }}
                transition={{ type: "spring", duration: 0.25, bounce: 0 }}
              >
                {STRENGTH[score].label}
              </motion.span>
            </AnimatePresence>
          </span>
        </div>
      )}

      {message && (
        <p
          id={messageId}
          role={invalid ? "alert" : undefined}
          className={cn(
            "mt-1.5 text-[13px]",
            invalid ? "text-destructive" : "text-muted-foreground",
          )}
        >
          {message}
        </p>
      )}
    </div>
  );
}