A single API-key row with the full reveal, copy, and rotate lifecycle; the secret is masked by default and rotation blur-crossfades the old key into the new one.
npx shadcn@latest add @paragon/api-key-row"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy, Eye, EyeOff, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ApiKeyRowProps extends React.ComponentProps<"div"> {
/** Human label for the key. */
name?: string;
/** Non-secret prefix shown even while masked, e.g. "sk_live_". */
prefix?: string;
/** Secret tail. Combined with prefix to form the full key. */
secret?: string;
/** Relative "last used" descriptor. */
lastUsed?: string;
/**
* Produces the next secret tail on rotate. Deterministic by default so the
* zero-props render never calls Math.random during hydration.
*/
onRotate?: (previous: string) => string;
}
/** Fixed-length mask so width never jumps between hidden and shown. */
function maskOf(secret: string) {
return "•".repeat(Math.max(secret.length, 8));
}
/**
* A single API-key row with the full reveal / copy / rotate lifecycle.
* Masked by default; revealing swaps the dot mask for the real tail behind a
* 2px blur crossfade. Rotating spins the icon once, then blur-crossfades the
* old tail out and the new one in (old → new) — the key value itself is never
* logged. Copy shows the house check-swap. Reduced motion drops the crossfades
* to plain swaps and the spin to a static state.
*/
export function ApiKeyRow({
name = "Production",
prefix = "sk_live_",
secret = "4mZ8qP1rT9vX2wL",
lastUsed = "2 hours ago",
onRotate,
className,
...props
}: ApiKeyRowProps) {
const reduced = useReducedMotion();
const [tail, setTail] = React.useState(secret);
const [revealed, setRevealed] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const [rotating, setRotating] = React.useState(false);
const copyTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
const spinTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
React.useEffect(() => {
return () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
if (spinTimer.current) clearTimeout(spinTimer.current);
};
}, []);
const copy = () => {
navigator.clipboard.writeText(prefix + tail);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
};
const rotate = () => {
const next = onRotate
? onRotate(tail)
: // Deterministic default rotation: rotate the alphabet of the tail.
tail.replace(/[a-zA-Z0-9]/g, (c) => {
if (/\d/.test(c)) return String((Number(c) + 3) % 10);
const base = c <= "Z" ? 65 : 97;
return String.fromCharCode(((c.charCodeAt(0) - base + 5) % 26) + base);
});
setRotating(true);
setTail(next);
if (spinTimer.current) clearTimeout(spinTimer.current);
spinTimer.current = setTimeout(() => setRotating(false), 450);
};
const shown = revealed ? tail : maskOf(tail);
return (
<div
data-slot="api-key-row"
className={cn(
"flex items-center gap-3 rounded-xl bg-card px-4 py-3 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{name}</span>
<span className="text-[11px] text-muted-foreground">
Last used {lastUsed}
</span>
</div>
<div className="mt-1 flex items-center font-mono text-[13px] leading-none">
<span className="text-muted-foreground">{prefix}</span>
{/* aria-live announces reveal/rotate without leaking value to logs. */}
<span className="relative inline-block">
{/* Ghost sizer holds width so crossfades don't reflow. */}
<span aria-hidden className="invisible tabular-nums">
{tail}
</span>
<span className="sr-only">
{revealed ? "key revealed" : "key hidden"}
</span>
{reduced ? (
<span
aria-hidden
className="absolute inset-0 tabular-nums text-foreground"
>
{shown}
</span>
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${revealed ? "r" : "m"}-${tail}`}
aria-hidden
className="absolute inset-0 tabular-nums text-foreground"
initial={{ opacity: 0, filter: "blur(2px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(2px)" }}
transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
>
{shown}
</motion.span>
</AnimatePresence>
)}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<IconAction
label={revealed ? "Hide key" : "Reveal key"}
onClick={() => setRevealed((v) => !v)}
>
{revealed ? <EyeOff /> : <Eye />}
</IconAction>
<IconAction label="Copy key" onClick={copy}>
<span className="relative flex size-4 items-center justify-center">
<Copy
className={cn(
"absolute transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out)",
copied ? "scale-50 opacity-0" : "scale-100 opacity-100",
)}
/>
<Check
className={cn(
"absolute text-success transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out)",
copied ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
/>
</span>
</IconAction>
<IconAction label="Rotate key" onClick={rotate}>
<RefreshCw
className={cn(
"transition-transform duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
rotating && "rotate-[360deg]",
)}
/>
</IconAction>
</div>
</div>
);
}
function IconAction({
label,
children,
className,
...props
}: React.ComponentProps<"button"> & { label: string }) {
return (
<button
type="button"
aria-label={label}
className={cn(
"pressable relative flex size-8 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",
"[&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
</button>
);
}