Password Input
Inputs & Forms

Password Input

A password field with an icon-swap visibility toggle, a caps-lock warning, 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. Hidden while an error is shown. */
  hint?: string;
  /** Error message. Turns the field red, fades the message in, shakes once. */
  error?: string;
  /** Success state. Pass a string to also show a confirmation message. */
  success?: boolean | string;
  /** Show the 4-segment strength meter below the field. */
  showStrength?: boolean;
  /**
   * Change this value to replay the shake even when the error message is
   * unchanged (e.g. increment on every failed submit).
   */
  shakeKey?: string | number;
}

/* Strength scale shared with password-generator: destructive → warning →
 * primary → success. Neutral until the password is genuinely strong. */
const STRENGTH = [
  { label: "", bar: "", text: "text-muted-foreground" },
  { label: "Weak", bar: "bg-destructive", text: "text-destructive" },
  { label: "Fair", bar: "bg-warning", text: "text-warning" },
  { label: "Good", bar: "bg-primary", text: "text-primary" },
  { label: "Strong", bar: "bg-success", text: "text-success" },
] 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, a caps-lock warning,
 * and an optional strength meter: four segments fill with 50ms staggered
 * delays, the color steps destructive → warning → primary → success, and the
 * strength label crossfades. Errors shake once, matching the Input recipe.
 */
export function PasswordInput({
  label,
  hint,
  error,
  success,
  showStrength = false,
  shakeKey,
  className,
  id: idProp,
  value,
  defaultValue,
  onChange,
  onKeyDown,
  onKeyUp,
  onBlur,
  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 [capsLock, setCapsLock] = React.useState(false);
  const [internal, setInternal] = React.useState(String(defaultValue ?? ""));
  const current = value !== undefined ? String(value) : internal;
  const score = scorePassword(current);

  const [shaking, setShaking] = React.useState(false);
  React.useEffect(() => {
    if (error) setShaking(true);
  }, [error, shakeKey]);

  const invalid = Boolean(error);
  const successful = !invalid && Boolean(success);
  const capsMessage = capsLock && !disabled ? "Caps Lock is on." : undefined;
  const message =
    error ??
    capsMessage ??
    (typeof success === "string" ? success : undefined) ??
    hint;

  const syncCapsLock = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (typeof event.getModifierState === "function") {
      setCapsLock(event.getModifierState("CapsLock"));
    }
  };

  return (
    <div className="w-full">
      <style href="paragon-password-input" precedence="paragon">{`
        @keyframes paragon-password-shake {
          0% { translate: 0; animation-timing-function: cubic-bezier(0.36, 0, 0.66, 0.2); }
          25% { translate: -6px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
          50% { translate: 5px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
          75% { translate: -3px 0; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
          100% { translate: 0; }
        }
        @keyframes paragon-password-message-in {
          from { opacity: 0; translate: 0 -2px; filter: blur(2px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .paragon-password-shake { animation: none !important; }
          .paragon-password-message { animation: none !important; }
        }
      `}</style>

      {label && (
        <label
          htmlFor={id}
          className="mb-1.5 block text-sm font-medium text-foreground"
        >
          {label}
        </label>
      )}
      <div
        className={cn("relative", shaking && "paragon-password-shake")}
        style={
          shaking
            ? { animation: "paragon-password-shake 280ms both" }
            : undefined
        }
        onAnimationEnd={(event) => {
          if (event.animationName === "paragon-password-shake") {
            setShaking(false);
          }
        }}
      >
        <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);
          }}
          onKeyDown={(event) => {
            syncCapsLock(event);
            onKeyDown?.(event);
          }}
          onKeyUp={(event) => {
            syncCapsLock(event);
            onKeyUp?.(event);
          }}
          onBlur={(event) => {
            setCapsLock(false);
            onBlur?.(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",
            successful &&
              "border-success/60 focus-visible:border-success focus-visible:ring-success/20",
            !invalid &&
              !successful &&
              "hover:not-disabled:not-focus-visible:border-ring/60",
            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 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring 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].bar : "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[score].text,
            )}
          >
            <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}
          // Keyed so a new message re-runs the fade-in; the shake is a
          // separate class on the field wrapper, so replaying it never
          // flickers this element.
          key={message}
          className={cn(
            "paragon-password-message mt-1.5 text-[13px]",
            invalid
              ? "text-destructive"
              : capsMessage && message === capsMessage
                ? "text-warning"
                : successful && typeof success === "string"
                  ? "text-success"
                  : "text-muted-foreground",
          )}
          style={{
            animation: "paragon-password-message-in 200ms var(--ease-out) both",
          }}
        >
          {message}
        </p>
      )}
    </div>
  );
}