Data Display

API Key Row

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.

Install

npx shadcn@latest add @paragon/api-key-row

api-key-row.tsx

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