A masked secret guarded behind hold-to-reveal; a clip-path fill tracks the hold, the value blur-crossfades in, then auto re-masks. Includes copy.
npx shadcn@latest add @paragon/secret-reveal"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy, Eye, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
type Phase = "masked" | "holding" | "revealed";
export interface SecretRevealProps extends React.ComponentProps<"div"> {
/** Label above the secret. */
label?: string;
/** The secret value. Masked by default; never logged. */
value?: string;
/** How long to hold before it reveals, in ms. */
holdDuration?: number;
/** How long the secret stays revealed before auto re-masking, in ms. */
revealDuration?: number;
}
/** Fixed-width dot mask so the field never resizes between states. */
function maskOf(value: string) {
return "•".repeat(Math.max(value.length, 12));
}
/**
* A secret field guarded behind hold-to-reveal. Press and hold the button — a
* fill wipes across it via a clip-path transition timed to holdDuration;
* releasing early retargets the same transition back (never a keyframe, so it's
* interruptible). Complete the hold and the value blur-crossfades in, then
* auto re-masks after revealDuration so it can't linger on screen. Copy works
* while revealed and never logs the value. Space/Enter drive the hold for
* keyboard users; reduced motion swaps the wipe for a fade.
*/
export function SecretReveal({
label = "Signing secret",
value = "whsec_3aF9kQ2mN7pR1xY8vL4cD6bT0wZ",
holdDuration = 900,
revealDuration = 6000,
className,
...props
}: SecretRevealProps) {
const reduced = useReducedMotion();
const [phase, setPhase] = React.useState<Phase>("masked");
const [copied, setCopied] = React.useState(false);
const holdTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
const maskTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
const copyTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
React.useEffect(() => {
return () => {
if (holdTimer.current) clearTimeout(holdTimer.current);
if (maskTimer.current) clearTimeout(maskTimer.current);
if (copyTimer.current) clearTimeout(copyTimer.current);
};
}, []);
const startHold = () => {
if (phase === "revealed") return;
setPhase("holding");
holdTimer.current = setTimeout(() => {
setPhase("revealed");
maskTimer.current = setTimeout(() => setPhase("masked"), revealDuration);
}, holdDuration);
};
const cancelHold = () => {
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
setPhase((p) => (p === "holding" ? "masked" : p));
};
const reMask = () => {
if (maskTimer.current) clearTimeout(maskTimer.current);
setPhase("masked");
};
const copy = () => {
navigator.clipboard.writeText(value);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
};
const revealed = phase === "revealed";
const shown = revealed ? value : maskOf(value);
return (
<div
data-slot="secret-reveal"
className={cn(
"w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
{label}
</span>
{revealed && (
<button
type="button"
onClick={reMask}
className="text-[11px] font-medium text-muted-foreground transition-colors duration-(--duration-fast) hover:text-foreground rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Hide
</button>
)}
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center rounded-lg border bg-background px-3 py-2.5 font-mono text-[13px]">
{reduced ? (
<span className="truncate">{shown}</span>
) : (
<span className="relative block min-w-0 flex-1 overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={revealed ? "shown" : "masked"}
className="block truncate"
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(4px)" }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
>
{shown}
</motion.span>
</AnimatePresence>
</span>
)}
<span className="sr-only" aria-live="polite">
{revealed ? "secret revealed" : "secret hidden"}
</span>
</div>
{revealed ? (
<button
type="button"
aria-label="Copy secret"
onClick={copy}
className={cn(
"pressable relative flex size-10 shrink-0 items-center justify-center rounded-lg bg-card shadow-border",
"transition-[box-shadow] duration-(--duration-fast) hover:shadow-border-hover",
"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 text-muted-foreground">
<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>
) : (
<button
type="button"
aria-label="Hold to reveal secret"
style={
{ "--sr-hold": `${holdDuration}ms` } as React.CSSProperties
}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
startHold();
}}
onPointerUp={cancelHold}
onPointerCancel={cancelHold}
onKeyDown={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (!e.repeat) startHold();
}
}}
onKeyUp={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
cancelHold();
}
}}
onBlur={cancelHold}
onContextMenu={(e) => {
if (phase === "holding") e.preventDefault();
}}
className={cn(
"pressable relative flex h-10 shrink-0 touch-none items-center gap-1.5 overflow-hidden rounded-lg bg-card px-3 text-[13px] font-medium whitespace-nowrap select-none shadow-border",
"transition-[box-shadow] duration-(--duration-fast) hover:shadow-border-hover",
"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",
)}
>
{/* Hold-progress fill — clip-path wipe, retargets on release. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 bg-primary/12",
"transition-[clip-path] motion-reduce:transition-[opacity]",
phase === "holding"
? "ease-linear [clip-path:inset(0_0_0_0)] [transition-duration:var(--sr-hold)]"
: "duration-200 ease-out [clip-path:inset(0_100%_0_0)] motion-reduce:opacity-0",
)}
/>
<span className="relative flex items-center gap-1.5 text-muted-foreground">
{phase === "holding" ? (
<Eye className="text-primary" />
) : (
<Lock />
)}
Hold to reveal
</span>
</button>
)}
</div>
</div>
);
}