Fintech & Payments

Payment Link

A shareable payment-link card with a big amount, a copy field that blur-swaps to a check, an open action, and a deterministic seeded QR placeholder.

Install

npx shadcn@latest add @paragon/payment-link

payment-link.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy, ExternalLink, QrCode } from "lucide-react";
import { cn } from "@/lib/utils";

export interface PaymentLinkProps extends React.ComponentProps<"div"> {
  amount: number;
  currency?: string;
  /** The shareable URL. */
  url: string;
  title?: string;
  /** Short seed used to pattern the QR placeholder deterministically. */
  qrSeed?: string;
  status?: "active" | "paid" | "expired";
}

/** Deterministic 0/1 grid from a seed — a stylized QR placeholder, no data. */
function qrCells(seed: string, size = 11): boolean[] {
  let h = 2166136261;
  for (let i = 0; i < seed.length; i++) {
    h ^= seed.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  const cells: boolean[] = [];
  for (let i = 0; i < size * size; i++) {
    h ^= h << 13;
    h ^= h >>> 17;
    h ^= h << 5;
    cells.push((h & 1) === 1);
  }
  return cells;
}

/**
 * A shareable payment-link card: a big amount, the URL in a fused field with
 * a copy button that blur-swaps its icon to a check on success, an open
 * action, and a deterministic QR placeholder (seeded, never random — safe for
 * SSR) that toggles into view. A status pill reads active / paid / expired.
 * Everything is tabular and both themes render with zero props.
 */
export function PaymentLink({
  amount,
  currency = "USD",
  url,
  title = "Payment request",
  qrSeed,
  status = "active",
  className,
  ...props
}: PaymentLinkProps) {
  const reduced = useReducedMotion();
  const [copied, setCopied] = React.useState(false);
  const [showQr, setShowQr] = React.useState(false);
  const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);

  const money = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
  }).format(amount);

  const cells = React.useMemo(() => qrCells(qrSeed ?? url), [qrSeed, url]);
  const grid = Math.sqrt(cells.length);

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

  function copy() {
    navigator.clipboard?.writeText(url);
    setCopied(true);
    if (timeout.current) clearTimeout(timeout.current);
    timeout.current = setTimeout(() => setCopied(false), 1500);
  }

  const statusMeta = {
    active: { label: "Active", cls: "bg-success/10 text-success" },
    paid: { label: "Paid", cls: "bg-muted text-muted-foreground" },
    expired: { label: "Expired", cls: "bg-destructive/10 text-destructive" },
  }[status];

  return (
    <div
      className={cn(
        "w-full max-w-sm rounded-xl bg-card p-5 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-start justify-between">
        <div>
          <p className="text-[13px] font-medium text-muted-foreground">
            {title}
          </p>
          <p className="mt-0.5 text-2xl font-semibold tabular-nums">{money}</p>
        </div>
        <span
          className={cn(
            "rounded-full px-2 py-0.5 text-[11px] font-medium",
            statusMeta.cls,
          )}
        >
          {statusMeta.label}
        </span>
      </div>

      <div className="mt-4 flex items-stretch overflow-hidden rounded-lg border">
        <span className="flex min-w-0 flex-1 items-center truncate bg-muted/40 px-3 text-[13px] text-muted-foreground">
          {url}
        </span>
        <button
          type="button"
          onClick={copy}
          aria-label="Copy payment link"
          className="relative flex w-10 shrink-0 items-center justify-center border-l text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={copied ? "check" : "copy"}
              initial={reduced ? false : { opacity: 0, scale: 0.4, filter: "blur(4px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(4px)" }}
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            >
              {copied ? (
                <Check className="size-4 text-success" aria-hidden />
              ) : (
                <Copy className="size-4" aria-hidden />
              )}
            </motion.span>
          </AnimatePresence>
        </button>
      </div>

      <div className="mt-3 flex gap-2">
        <a
          href={url}
          target="_blank"
          rel="noreferrer noopener"
          className="pressable flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary text-sm font-medium text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          <ExternalLink className="size-4" aria-hidden />
          Open link
        </a>
        <button
          type="button"
          onClick={() => setShowQr((s) => !s)}
          aria-pressed={showQr}
          aria-label={showQr ? "Hide QR code" : "Show QR code"}
          className={cn(
            "pressable flex h-9 items-center gap-1.5 rounded-lg px-3 text-sm 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",
            showQr ? "bg-accent" : "hover:bg-accent/50",
          )}
        >
          <QrCode className="size-4" aria-hidden />
          QR
        </button>
      </div>

      <div
        className={cn(
          "grid transition-[grid-template-rows] duration-250 ease-out motion-reduce:transition-none",
          showQr ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
        )}
      >
        <div className="overflow-hidden">
          <AnimatePresence initial={false}>
            {showQr && (
              <motion.div
                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                className="mt-4 flex justify-center"
              >
                <div className="rounded-lg bg-white p-3 shadow-border">
                  <svg
                    viewBox={`0 0 ${grid} ${grid}`}
                    className="size-32"
                    aria-label="QR code placeholder"
                    role="img"
                  >
                    {cells.map((on, i) =>
                      on ? (
                        <rect
                          key={i}
                          x={i % grid}
                          y={Math.floor(i / grid)}
                          width={1}
                          height={1}
                          fill="#111"
                        />
                      ) : null,
                    )}
                    {/* Finder squares */}
                    {[
                      [0, 0],
                      [grid - 3, 0],
                      [0, grid - 3],
                    ].map(([fx, fy], k) => (
                      <g key={k} fill="#111">
                        <rect x={fx} y={fy} width={3} height={3} />
                        <rect x={fx + 1} y={fy + 1} width={1} height={1} fill="#fff" />
                      </g>
                    ))}
                  </svg>
                </div>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}