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.
npx shadcn@latest add @paragon/totp-codeAlso installs: digit-roll
"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>
);
}