A stylized virtual card with a blur-masked reveal toggle, a freeze action that desaturates the face, and a corner spend-limit ring that draws to the used fraction.
npx shadcn@latest add @paragon/virtual-card"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Copy, Eye, EyeOff, Snowflake } from "lucide-react";
import { cn } from "@/lib/utils";
export interface VirtualCardProps extends React.ComponentProps<"div"> {
holder: string;
/** Full PAN, digits only or spaced — masked until revealed. */
number: string;
expiry: string;
cvc: string;
/** Amount spent this cycle, in dollars. */
spent: number;
/** Spend limit for the cycle, in dollars. */
limit: number;
currency?: string;
label?: string;
/** Two-stop gradient for the card face. */
gradient?: [string, string];
defaultFrozen?: boolean;
}
function maskNumber(digits: string) {
const groups = digits.match(/.{1,4}/g) ?? [];
return groups
.map((g, i) => (i < groups.length - 1 ? "••••" : g))
.join(" ");
}
const CIRC = 2 * Math.PI * 20;
/**
* A stylized virtual card. The PAN sits behind a blur privacy mask that
* lifts on the eye toggle (filter transition, reduced-motion snaps). A freeze
* toggle desaturates and dims the face — frozen cards can't be revealed. A
* spend-limit ring in the corner draws to the used fraction on mount and
* turns warning/destructive as it fills. Numbers are tabular; the copy action
* grabs the unmasked PAN only while revealed.
*/
export function VirtualCard({
holder,
number,
expiry,
cvc,
spent,
limit,
currency = "USD",
label = "Virtual card",
gradient = ["#4f46e5", "#0ea5e9"],
defaultFrozen = false,
className,
...props
}: VirtualCardProps) {
const reduced = useReducedMotion();
const [revealed, setRevealed] = React.useState(false);
const [frozen, setFrozen] = React.useState(defaultFrozen);
const [drawn, setDrawn] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);
const digits = number.replace(/\D/g, "");
const money = new Intl.NumberFormat("en-US", { style: "currency", currency });
const pct = limit > 0 ? Math.min(1, spent / limit) : 0;
const ringColor =
pct >= 1 ? "var(--color-destructive)" : pct >= 0.85 ? "var(--color-warning)" : "#ffffff";
React.useEffect(() => {
const id = requestAnimationFrame(() => setDrawn(true));
return () => cancelAnimationFrame(id);
}, []);
React.useEffect(
() => () => {
if (timeout.current) clearTimeout(timeout.current);
},
[],
);
const showDigits = revealed && !frozen;
function copyNumber() {
if (!showDigits) return;
navigator.clipboard?.writeText(digits);
setCopied(true);
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => setCopied(false), 1500);
}
return (
<div className={cn("w-full max-w-sm", className)} {...props}>
<div
className={cn(
"relative aspect-[1.586] w-full overflow-hidden rounded-2xl p-5 text-white shadow-border transition-[filter,opacity] duration-300 ease-out motion-reduce:transition-none",
frozen && "opacity-90 grayscale",
)}
style={{
backgroundImage: `linear-gradient(135deg, ${gradient[0]}, ${gradient[1]})`,
}}
>
{/* Decorative sheen */}
<div
aria-hidden
className="pointer-events-none absolute -right-8 -top-16 size-48 rounded-full bg-white/15 blur-2xl"
/>
<div className="relative flex items-start justify-between">
<span className="text-[13px] font-medium text-white/80">{label}</span>
{/* Spend-limit ring */}
<div className="relative size-11">
<svg viewBox="0 0 48 48" className="size-11 -rotate-90">
<circle
cx="24"
cy="24"
r="20"
fill="none"
stroke="rgba(255,255,255,0.25)"
strokeWidth="4"
/>
<circle
cx="24"
cy="24"
r="20"
fill="none"
stroke={ringColor}
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={CIRC}
strokeDashoffset={reduced ? CIRC * (1 - pct) : drawn ? CIRC * (1 - pct) : CIRC}
style={{
transition: reduced
? undefined
: "stroke-dashoffset 700ms var(--ease-out), stroke 300ms var(--ease-out)",
}}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[11px] font-semibold tabular-nums">
{Math.round(pct * 100)}%
</span>
</div>
</div>
<div className="relative mt-6 flex items-center gap-2 font-mono text-lg tracking-wider">
<span
className={cn(
"tabular-nums transition-[filter] duration-300 ease-out motion-reduce:transition-none",
showDigits ? "blur-0 select-text" : "select-none blur-[6px]",
)}
>
{showDigits ? maskNumber(digits) : maskNumber(digits.slice(0, 4) + "0".repeat(12))}
</span>
<span className="sr-only">
{showDigits ? `Card number ${digits}` : "Card number hidden"}
</span>
</div>
<div className="relative mt-4 flex items-end justify-between">
<div>
<p className="text-[10px] uppercase tracking-wide text-white/60">
Card holder
</p>
<p className="text-sm font-medium">{holder}</p>
</div>
<div className="flex gap-4 text-right">
<div>
<p className="text-[10px] uppercase tracking-wide text-white/60">
Exp
</p>
<p className="text-sm font-medium tabular-nums">
{showDigits ? expiry : "••/••"}
</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wide text-white/60">
CVC
</p>
<p className="text-sm font-medium tabular-nums">
{showDigits ? cvc : "•••"}
</p>
</div>
</div>
</div>
{frozen && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20"
>
<Snowflake className="size-10 text-white/90" />
</div>
)}
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-[13px] text-muted-foreground tabular-nums">
{money.format(spent)}{" "}
<span className="text-muted-foreground/70">
of {money.format(limit)}
</span>
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setRevealed((r) => !r)}
disabled={frozen}
aria-pressed={revealed}
aria-label={revealed ? "Hide card details" : "Reveal card details"}
className="pressable flex size-9 items-center justify-center rounded-lg text-muted-foreground shadow-border transition-colors duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40"
>
{revealed ? (
<EyeOff className="size-4" aria-hidden />
) : (
<Eye className="size-4" aria-hidden />
)}
</button>
<button
type="button"
onClick={copyNumber}
disabled={!showDigits}
aria-label="Copy card number"
className="pressable flex size-9 items-center justify-center rounded-lg text-muted-foreground shadow-border transition-colors duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={copied ? "done" : "copy"}
initial={reduced ? false : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="text-[11px] font-medium"
>
{copied ? "Copied" : <Copy className="size-4" aria-hidden />}
</motion.span>
</AnimatePresence>
</button>
<button
type="button"
onClick={() => {
setFrozen((f) => !f);
if (!frozen) setRevealed(false);
}}
aria-pressed={frozen}
aria-label={frozen ? "Unfreeze card" : "Freeze card"}
className={cn(
"pressable flex h-9 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium shadow-border transition-colors duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
frozen
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<Snowflake className="size-4" aria-hidden />
{frozen ? "Frozen" : "Freeze"}
</button>
</div>
</div>
</div>
);
}