A password-manager entry with title, username, a mask-toggling password field, per-field copy, and a deterministic strength meter.
npx shadcn@latest add @paragon/vault-item"use client";
import * as React from "react";
import { Check, Copy, Eye, EyeOff, Globe } from "lucide-react";
import { cn } from "@/lib/utils";
export interface VaultItemProps extends React.ComponentProps<"div"> {
/** Entry title. */
title?: string;
/** Account username / email. */
username?: string;
/** The stored password. Masked by default; never logged. */
password?: string;
/** Site the entry belongs to. */
site?: string;
/**
* Password strength 0–4 (weak → strong). If omitted it is derived
* deterministically from the password composition.
*/
strength?: number;
/** Disables enter animation. */
static?: boolean;
}
const STRENGTH_META = [
{ label: "Very weak", color: "bg-destructive", text: "text-destructive" },
{ label: "Weak", color: "bg-destructive", text: "text-destructive" },
{ label: "Fair", color: "bg-warning", text: "text-warning" },
{ label: "Good", color: "bg-success", text: "text-success" },
{ label: "Strong", color: "bg-success", text: "text-success" },
];
/** Deterministic strength estimate from composition — no randomness. */
function scoreOf(pw: string) {
let s = 0;
if (pw.length >= 12) s += 2;
else if (pw.length >= 8) s += 1;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s += 1;
if (/\d/.test(pw)) s += 1;
if (/[^A-Za-z0-9]/.test(pw)) s += 1;
return Math.min(s, 4);
}
/**
* A password-manager vault entry: title, username, masked password, and a
* strength meter. The password toggles between a fixed-width dot mask and the
* value via a CSS opacity/blur transition, and each field copies with an
* inline check swap. Copying never logs the value. Strength defaults to a
* deterministic estimate from the password composition.
*/
export function VaultItem({
title = "Production database",
username = "svc-billing@acme.com",
password = "9xK!mQ2$vLp7#nR4",
site = "console.aws.amazon.com",
strength,
static: isStatic = false,
className,
...props
}: VaultItemProps) {
const [revealed, setRevealed] = React.useState(false);
const [copied, setCopied] = React.useState<"user" | "pass" | null>(null);
const timer = React.useRef<ReturnType<typeof setTimeout>>(null);
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const score = strength ?? scoreOf(password);
const meta = STRENGTH_META[Math.min(Math.max(score, 0), 4)];
const mask = "•".repeat(Math.max(password.length, 12));
const copy = (value: string, which: "user" | "pass") => {
navigator.clipboard.writeText(value);
setCopied(which);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(null), 1500);
};
return (
<div
data-slot="vault-item"
className={cn(
"w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
style={
!isStatic
? ({
animation: "vault-enter var(--duration-base) var(--ease-out)",
} as React.CSSProperties)
: undefined
}
{...props}
>
<style href="paragon-vault-item" precedence="paragon">{`
@keyframes vault-enter {
from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
[data-slot="vault-item"] { animation: none !important; }
}
`}</style>
<div className="flex items-center gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-secondary text-muted-foreground">
<Globe className="size-4.5" />
</span>
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold">{title}</h3>
<p className="truncate text-xs text-muted-foreground">{site}</p>
</div>
</div>
<dl className="mt-4 space-y-2">
<Field
label="Username"
copied={copied === "user"}
onCopy={() => copy(username, "user")}
>
<span className="truncate font-mono text-[13px]">{username}</span>
</Field>
<Field
label="Password"
copied={copied === "pass"}
onCopy={() => copy(password, "pass")}
leading={
<button
type="button"
aria-label={revealed ? "Hide password" : "Show password"}
onClick={() => setRevealed((r) => !r)}
className="pressable flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--duration-fast) hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
}
>
<span className="relative block min-w-0 flex-1 font-mono text-[13px]">
<span
className={cn(
"block truncate transition-[opacity,filter] duration-(--duration-quick) ease-(--ease-out)",
revealed ? "opacity-100 blur-0" : "opacity-0 blur-[3px]",
)}
aria-hidden={!revealed}
>
{password}
</span>
<span
className={cn(
"absolute inset-0 block truncate transition-[opacity] duration-(--duration-quick) ease-(--ease-out)",
revealed ? "opacity-0" : "opacity-100",
)}
aria-hidden={revealed}
>
{mask}
</span>
</span>
</Field>
</dl>
{/* Strength meter */}
<div className="mt-4 flex items-center gap-3">
<div className="flex flex-1 gap-1" role="img" aria-label={`Password strength: ${meta.label}`}>
{[0, 1, 2, 3].map((i) => (
<span
key={i}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors duration-(--duration-base) ease-(--ease-out)",
i < score ? meta.color : "bg-border",
)}
/>
))}
</div>
<span className={cn("text-xs font-medium tabular-nums", meta.text)}>
{meta.label}
</span>
</div>
</div>
);
}
function Field({
label,
copied,
onCopy,
leading,
children,
}: {
label: string;
copied: boolean;
onCopy: () => void;
leading?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2">
<div className="min-w-0 flex-1">
<dt className="text-[11px] font-medium text-muted-foreground">{label}</dt>
<dd className="mt-0.5 flex items-center">{children}</dd>
</div>
{leading}
<button
type="button"
aria-label={`Copy ${label.toLowerCase()}`}
onClick={onCopy}
className="pressable relative flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--duration-fast) hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<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>
);
}