Fintech & Payments

Virtual Card

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.

Install

npx shadcn@latest add @paragon/virtual-card

virtual-card.tsx

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