Inputs & Forms

Password Generator

A password generator with a length slider, character-class toggles, and a strength meter; regenerating scrambles the characters and resolves them into the new value.

Install

npx shadcn@latest add @paragon/password-generator

Also installs: slider, switch

password-generator.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { Check, Copy, RefreshCw } from "lucide-react";
import { Slider } from "@/registry/paragon/ui/slider";
import { Switch } from "@/registry/paragon/ui/switch";
import { cn } from "@/lib/utils";

const SETS = {
  lower: "abcdefghijklmnopqrstuvwxyz",
  upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
  number: "0123456789",
  symbol: "!@#$%^&*-_=+?",
};

const SCRAMBLE_POOL =
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";

/** Deterministic 32-bit PRNG (mulberry32) — stable across server/client. */
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

interface Options {
  length: number;
  lower: boolean;
  upper: boolean;
  number: boolean;
  symbol: boolean;
}

function buildPool(opts: Options) {
  let pool = "";
  if (opts.lower) pool += SETS.lower;
  if (opts.upper) pool += SETS.upper;
  if (opts.number) pool += SETS.number;
  if (opts.symbol) pool += SETS.symbol;
  return pool || SETS.lower;
}

function generate(opts: Options, seed: number) {
  const pool = buildPool(opts);
  const rand = mulberry32(seed);
  let out = "";
  for (let i = 0; i < opts.length; i++) {
    out += pool[Math.floor(rand() * pool.length)];
  }
  return out;
}

/** Rough strength score 0–4 from length and character-class variety. */
function strengthOf(opts: Options) {
  const classes =
    Number(opts.lower) +
    Number(opts.upper) +
    Number(opts.number) +
    Number(opts.symbol);
  let score = 0;
  if (opts.length >= 8) score++;
  if (opts.length >= 14) score++;
  if (opts.length >= 20) score++;
  if (classes >= 3) score++;
  return Math.min(4, Math.max(1, score)) as 1 | 2 | 3 | 4;
}

const strengthMeta: Record<
  number,
  { label: string; bar: string; text: string }
> = {
  1: { label: "Weak", bar: "bg-destructive", text: "text-destructive" },
  2: { label: "Fair", bar: "bg-warning", text: "text-warning" },
  3: { label: "Good", bar: "bg-primary", text: "text-primary" },
  4: { label: "Strong", bar: "bg-success", text: "text-success" },
};

export interface PasswordGeneratorProps extends React.ComponentProps<"div"> {
  defaultLength?: number;
  /** Deterministic starting seed so the first render is reproducible. */
  seed?: number;
}

/**
 * A password generator: a length slider, character-class toggles, and a
 * strength meter. Regenerating scrambles every character in place and resolves
 * them left-to-right into the new password (a seeded glyph churn — never
 * Math.random during render, so hydration is stable). Copy shows the house
 * check-swap and never logs the value. Changing options regenerates too.
 * Reduced motion swaps to the final string with no churn.
 */
export function PasswordGenerator({
  defaultLength = 20,
  seed = 1,
  className,
  ...props
}: PasswordGeneratorProps) {
  const reduced = useReducedMotion();
  const [opts, setOpts] = React.useState<Options>({
    length: defaultLength,
    lower: true,
    upper: true,
    number: true,
    symbol: true,
  });
  const [nonce, setNonce] = React.useState(seed);
  const [copied, setCopied] = React.useState(false);
  const copyTimer = React.useRef<ReturnType<typeof setTimeout>>(null);

  const password = React.useMemo(() => generate(opts, nonce), [opts, nonce]);
  const strength = strengthOf(opts);
  const meta = strengthMeta[strength];

  // Scramble churn state, keyed to the current password.
  const [display, setDisplay] = React.useState(password);
  const chars = React.useMemo(() => password.split(""), [password]);

  React.useEffect(() => {
    return () => {
      if (copyTimer.current) clearTimeout(copyTimer.current);
    };
  }, []);

  React.useEffect(() => {
    if (reduced) {
      setDisplay(password);
      return;
    }
    const rand = mulberry32((nonce ^ 0x9e3779b9) >>> 0);
    const stagger = 22;
    const speed = 34;
    let raf = 0;
    let start: number | null = null;
    let lastSwap = -Infinity;
    const tick = (now: number) => {
      if (start === null) start = now;
      const locked = Math.min(chars.length, Math.floor((now - start) / stagger));
      if (locked >= chars.length) {
        setDisplay(password);
        return;
      }
      if (now - lastSwap >= speed) {
        lastSwap = now;
        setDisplay(
          chars
            .map((ch, i) =>
              i < locked
                ? ch
                : SCRAMBLE_POOL[Math.floor(rand() * SCRAMBLE_POOL.length)],
            )
            .join(""),
        );
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [password, chars, nonce, reduced]);

  const regenerate = () => setNonce((n) => (n * 1103515245 + 12345) & 0x7fffffff);

  const copy = () => {
    navigator.clipboard.writeText(password);
    setCopied(true);
    if (copyTimer.current) clearTimeout(copyTimer.current);
    copyTimer.current = setTimeout(() => setCopied(false), 1500);
  };

  const toggle = (key: keyof Options) => (v: boolean) =>
    setOpts((prev) => {
      const next = { ...prev, [key]: v };
      // Never let all classes turn off.
      if (!next.lower && !next.upper && !next.number && !next.symbol) {
        return prev;
      }
      return next;
    });

  const toggleRows: Array<{ key: keyof Options; label: string }> = [
    { key: "upper", label: "Uppercase" },
    { key: "lower", label: "Lowercase" },
    { key: "number", label: "Numbers" },
    { key: "symbol", label: "Symbols" },
  ];

  return (
    <div
      data-slot="password-generator"
      className={cn(
        "w-full max-w-sm rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      {/* Password field. */}
      <div className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2.5">
        <code className="min-w-0 flex-1 truncate font-mono text-[15px] tracking-tight">
          <span className="sr-only">{password}</span>
          <span aria-hidden>{display}</span>
        </code>
        <button
          type="button"
          aria-label="Regenerate password"
          onClick={regenerate}
          className={cn(
            "pressable flex size-7 items-center justify-center rounded-md text-muted-foreground",
            "transition-colors duration-(--duration-quick) hover:bg-accent hover:text-foreground",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          )}
        >
          <RefreshCw className="size-4" />
        </button>
        <button
          type="button"
          aria-label="Copy password"
          onClick={copy}
          className={cn(
            "pressable relative flex size-7 items-center justify-center rounded-md text-muted-foreground",
            "transition-colors duration-(--duration-quick) hover:bg-accent hover:text-foreground",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          )}
        >
          <span className="relative flex size-4 items-center justify-center">
            <Copy
              className={cn(
                "absolute size-4 transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out)",
                copied ? "scale-50 opacity-0" : "scale-100 opacity-100",
              )}
            />
            <Check
              className={cn(
                "absolute size-4 text-success transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out)",
                copied ? "scale-100 opacity-100" : "scale-50 opacity-0",
              )}
            />
          </span>
        </button>
      </div>

      {/* Strength meter. */}
      <div className="mt-3 flex items-center gap-3">
        <div className="flex flex-1 gap-1" aria-hidden>
          {[1, 2, 3, 4].map((i) => (
            <span
              key={i}
              className={cn(
                "h-1 flex-1 rounded-full transition-colors duration-(--duration-base)",
                i <= strength ? meta.bar : "bg-secondary",
              )}
            />
          ))}
        </div>
        <span className={cn("text-xs font-medium", meta.text)}>
          {meta.label}
        </span>
      </div>

      {/* Length slider. */}
      <div className="mt-4">
        <div className="flex items-center justify-between text-xs">
          <span className="text-muted-foreground">Length</span>
          <span className="font-medium tabular-nums">{opts.length}</span>
        </div>
        <Slider
          className="mt-2"
          value={[opts.length]}
          min={8}
          max={40}
          step={1}
          aria-label="Password length"
          onValueChange={([v]) => setOpts((prev) => ({ ...prev, length: v }))}
        />
      </div>

      {/* Class toggles. */}
      <div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2.5">
        {toggleRows.map(({ key, label }) => (
          <label
            key={key}
            className="flex cursor-pointer items-center justify-between text-[13px]"
          >
            {label}
            <Switch
              checked={opts[key] as boolean}
              onCheckedChange={toggle(key)}
              aria-label={label}
            />
          </label>
        ))}
      </div>
    </div>
  );
}