Fintech & Payments

Payment Method Row

A selectable saved card or bank method whose selected state adds a ring and blur-swaps a check into the trailing marker, with an optional default badge.

Install

npx shadcn@latest add @paragon/payment-method-row

payment-method-row.tsx

"use client";

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

export interface PaymentMethodRowProps
  extends Omit<React.ComponentProps<"button">, "onChange"> {
  /** Card or bank account. */
  method?: "card" | "bank";
  /** Display name, e.g. "Visa" or "Chase checking". */
  name: string;
  /** Last four digits. */
  last4: string;
  /** Card expiry or bank detail line, e.g. "Exp 08/27". */
  meta?: string;
  /** Selected shows the ring + check. */
  selected?: boolean;
  /** Marks this the account default. */
  isDefault?: boolean;
}

/**
 * A selectable saved payment method. Selection is a real radio-like control:
 * the whole row is the target, the selected state adds a ring and blur-swaps a
 * check into the trailing marker (empty circle ↔ filled check). Because
 * picking a method is occasional, the swap gets the standard icon-swap spring;
 * the ring itself is a plain color transition so rapid re-selection retargets.
 */
export function PaymentMethodRow({
  method = "card",
  name,
  last4,
  meta,
  selected = false,
  isDefault = false,
  className,
  ...props
}: PaymentMethodRowProps) {
  const reduced = useReducedMotion();
  const Icon = method === "bank" ? Building2 : CreditCard;

  const marker = selected ? (
    <span
      key="on"
      className="flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground"
    >
      <Check className="size-3" strokeWidth={3} aria-hidden />
    </span>
  ) : (
    <span
      key="off"
      className="size-5 rounded-full border border-input"
      aria-hidden
    />
  );

  return (
    <button
      type="button"
      role="radio"
      aria-checked={selected}
      data-state={selected ? "checked" : "unchecked"}
      className={cn(
        "pressable flex w-full items-center gap-3 rounded-xl bg-card px-3.5 py-3 text-left",
        "transition-[box-shadow,background-color] duration-150 ease-out outline-none",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        selected
          ? "shadow-[0_0_0_2px_var(--color-ring)]"
          : "shadow-border hover:shadow-border-hover",
        className,
      )}
      {...props}
    >
      <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
        <Icon className="size-4" aria-hidden />
      </span>

      <span className="flex min-w-0 flex-1 flex-col">
        <span className="flex items-center gap-2">
          <span className="truncate text-sm font-medium">{name}</span>
          {isDefault && (
            <span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">
              Default
            </span>
          )}
        </span>
        <span className="text-[13px] tabular-nums text-muted-foreground">
          •••• {last4}
          {meta ? ` · ${meta}` : ""}
        </span>
      </span>

      <span className="flex size-5 shrink-0 items-center justify-center">
        {reduced ? (
          marker
        ) : (
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={selected ? "on" : "off"}
              initial={{ opacity: 0, scale: 0.4, filter: "blur(4px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, scale: 0.4, filter: "blur(4px)" }}
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            >
              {marker}
            </motion.span>
          </AnimatePresence>
        )}
      </span>
    </button>
  );
}