Data Display

TOTP Code

A one-time-passcode display with a 30-second countdown ring that depletes smoothly and a 6-digit code that rolls to the next value each step; includes copy.

Install

npx shadcn@latest add @paragon/totp-code

Also installs: digit-roll

totp-code.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { Check, Copy } from "lucide-react";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
import { cn } from "@/lib/utils";

export interface TotpCodeProps extends React.ComponentProps<"div"> {
  /** Account / issuer label. */
  issuer?: string;
  /** Account name shown under the issuer. */
  account?: string;
  /** Step period in seconds. */
  period?: number;
  /**
   * Produces the code for a given time-step counter. Deterministic default so
   * codes are reproducible and never call Math.random. Return a 6-digit string.
   */
  generate?: (step: number) => string;
  /** Ring diameter in px. */
  size?: number;
}

/** Deterministic 6-digit code from a step counter — a small LCG, not crypto. */
function defaultGenerate(step: number): string {
  let x = (step * 1103515245 + 12345) & 0x7fffffff;
  x = (x * 1103515245 + 12345) & 0x7fffffff;
  return String(x % 1_000_000).padStart(6, "0");
}

/**
 * A TOTP display: a 30-second countdown ring depletes smoothly (one linear
 * stroke-dashoffset transition per step — progress is the rare valid use of
 * linear), and when the step rolls over the 6-digit code rolls to the next
 * value on a shared odometer. Copy shows the house check-swap and never logs
 * the code. The clock is captured on mount, so nothing calls Date.now during
 * render. Under reduced motion the ring jumps per-second and the code swaps in
 * place.
 */
export function TotpCode({
  issuer = "Acme Cloud",
  account = "ada@acme.io",
  period = 30,
  generate = defaultGenerate,
  size = 36,
  className,
  ...props
}: TotpCodeProps) {
  const reduced = useReducedMotion();
  const [now, setNow] = React.useState<number | null>(null);
  const [copied, setCopied] = React.useState(false);
  const copyTimer = React.useRef<ReturnType<typeof setTimeout>>(null);

  React.useEffect(() => {
    setNow(Date.now());
    const id = setInterval(() => setNow(Date.now()), reduced ? 1000 : 250);
    return () => clearInterval(id);
  }, [reduced]);

  React.useEffect(() => {
    return () => {
      if (copyTimer.current) clearTimeout(copyTimer.current);
    };
  }, []);

  // Pre-hydration: render the first step deterministically (now = 0).
  const seconds = now === null ? 0 : (now / 1000) % period;
  const step = now === null ? 0 : Math.floor(now / 1000 / period);
  const remaining = period - seconds;
  const code = generate(step);

  const thickness = 3;
  const radius = (size - thickness) / 2;
  const circumference = 2 * Math.PI * radius;
  // Fraction of the period elapsed → how much of the ring is depleted.
  const fraction = seconds / period;
  const offset = circumference * fraction;
  const low = remaining <= 5;

  const copy = () => {
    navigator.clipboard.writeText(code);
    setCopied(true);
    if (copyTimer.current) clearTimeout(copyTimer.current);
    copyTimer.current = setTimeout(() => setCopied(false), 1500);
  };

  const formatted = `${code.slice(0, 3)} ${code.slice(3)}`;

  return (
    <div
      data-slot="totp-code"
      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">
        <p className="truncate text-[13px] font-medium">{issuer}</p>
        <p className="truncate text-xs text-muted-foreground">{account}</p>
        <div className="mt-1.5 flex items-center gap-2 font-mono text-2xl font-semibold tracking-[0.12em]">
          <DigitRoll
            aria-hidden
            value={Number(code.slice(0, 3))}
            formatOptions={{ minimumIntegerDigits: 3, useGrouping: false }}
          />
          <DigitRoll
            aria-hidden
            value={Number(code.slice(3))}
            formatOptions={{ minimumIntegerDigits: 3, useGrouping: false }}
          />
          <span className="sr-only">{formatted}</span>
        </div>
      </div>

      <div className="flex shrink-0 items-center gap-1">
        {/* Countdown ring. */}
        <div
          className="relative inline-flex items-center justify-center"
          style={{ width: size, height: size }}
          role="timer"
          aria-label={`Code refreshes in ${Math.ceil(remaining)} seconds`}
        >
          <svg
            aria-hidden
            width={size}
            height={size}
            viewBox={`0 0 ${size} ${size}`}
            fill="none"
            className="-rotate-90"
          >
            <circle
              cx={size / 2}
              cy={size / 2}
              r={radius}
              stroke="var(--color-secondary)"
              strokeWidth={thickness}
            />
            <circle
              cx={size / 2}
              cy={size / 2}
              r={radius}
              stroke={low ? "var(--color-destructive)" : "var(--color-primary)"}
              strokeWidth={thickness}
              strokeLinecap="round"
              strokeDasharray={circumference}
              strokeDashoffset={offset}
              // Linear because it tracks a real, continuous countdown. No
              // transition when the step wraps (offset jumps back to 0).
              style={{
                transition:
                  reduced || seconds < 0.3
                    ? "none"
                    : "stroke-dashoffset 250ms linear, stroke 200ms var(--ease-out)",
              }}
            />
          </svg>
          <span className="absolute text-[10px] font-medium text-muted-foreground tabular-nums">
            {Math.ceil(remaining)}
          </span>
        </div>

        <button
          type="button"
          aria-label="Copy code"
          onClick={copy}
          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",
          )}
        >
          <span className="relative flex size-4 items-center justify-center">
            <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>
      </div>
    </div>
  );
}