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.
npx shadcn@latest add @paragon/password-generatorAlso installs: slider, switch
"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>
);
}