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.
npx shadcn@latest add @paragon/payment-method-row"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>
);
}