API Key Row
Data Display

API Key Row

A single API-key row with the full reveal, copy, and rotate lifecycle; the secret is masked by default, rotation blur-crossfades the old key into the new one, and every icon action carries a tooltip.

Install

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

Also installs: tooltip

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 {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
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 winds the icon a full forward turn — a
 * retargeting transition, so mashing rotate keeps spinning forward instead of
 * rewinding — while the old tail blur-crossfades into the new one. Copy shows
 * the house check-swap; every icon action carries a tooltip and a ≥40px hit
 * area. 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);
  // Cumulative full turns — the transition retargets forward, never rewinds.
  const [turns, setTurns] = React.useState(0);
  const copyTimer = React.useRef<ReturnType<typeof setTimeout>>(null);

  React.useEffect(() => {
    return () => {
      if (copyTimer.current) clearTimeout(copyTimer.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);
        });
    setTurns((t) => t + 1);
    setTail(next);
  };

  const shown = revealed ? tail : maskOf(tail);
  // The sizer holds the wider of the two renderings (mono, so width == chars)
  // to keep the crossfade from ever reflowing the row.
  const sizer = tail.length >= maskOf(tail).length ? 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-baseline gap-2">
          <span className="min-w-0 truncate text-sm font-medium" title={name}>
            {name}
          </span>
          <span className="shrink-0 text-[11px] whitespace-nowrap 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">
              {sizer}
            </span>
            <span aria-live="polite" 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>

      <TooltipProvider>
        <div className="flex shrink-0 items-center gap-1">
          <Tooltip>
            <TooltipTrigger asChild>
              <IconAction
                label={revealed ? "Hide key" : "Reveal key"}
                aria-pressed={revealed}
                onClick={() => setRevealed((v) => !v)}
              >
                {revealed ? <EyeOff /> : <Eye />}
              </IconAction>
            </TooltipTrigger>
            <TooltipContent>{revealed ? "Hide key" : "Reveal key"}</TooltipContent>
          </Tooltip>
          <Tooltip>
            <TooltipTrigger asChild>
              <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>
            </TooltipTrigger>
            <TooltipContent>{copied ? "Copied" : "Copy key"}</TooltipContent>
          </Tooltip>
          <Tooltip>
            <TooltipTrigger asChild>
              <IconAction label="Rotate key" onClick={rotate}>
                <RefreshCw
                  className="transition-transform duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none"
                  style={{ transform: `rotate(${turns * 360}deg)` }}
                />
              </IconAction>
            </TooltipTrigger>
            <TooltipContent>Rotate key</TooltipContent>
          </Tooltip>
        </div>
      </TooltipProvider>
    </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",
        "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
        "[&_svg]:size-4 [&_svg]:shrink-0",
        className,
      )}
      {...props}
    >
      {children}
    </button>
  );
}