Data Display

Secret Reveal

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.

Install

npx shadcn@latest add @paragon/secret-reveal

secret-reveal.tsx

"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>
  );
}