ecomcn
All blocks

Cart & checkout/block

Payment selector

How the shopper pays: one ruled list of methods (card, wallet, pay in instalments, bank) where the chosen one opens onto your payment provider's own fields. It never renders a card-number field: each panel is a renderPanel slot for Stripe's Payment Element, Adyen's Drop-in, PayPal or a redirect notice, kept mounted once opened so a provider iframe keeps the half-typed card. Panels sit outside the radio group, so arrow keys move between methods without reaching into a provider's fields, yet render under their own method as one object. Instalment lines from the order total, unavailable methods with their reason in words, a provider error in the open panel, loading and empty states. The heavy rule slides to the chosen method and its panel grows to its measured height (Motion; a fade under reduced motion).

See payment selector in a working shop — the demo store is built from every ecomcn block.Open the demo store
$ npx shadcn@latest add @ecomcn/payment-selector
Older shadcn CLI, or want the registry pinned?

Register the namespace yourself, once per project, then run the same add command. {name} is literal — the CLI fills it in.

$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.json
Preview — live
100%

Usage

With Stripe’s Payment Element in the card panel
import { PaymentSelector, type PaymentMethod } from "@/components/ecomcn/payment-selector"
import { PaymentElement } from "@stripe/react-stripe-js"

const methods: PaymentMethod[] = [
  { id: "card", label: "Card", kind: "card", description: "Visa, Mastercard, Amex" },
  { id: "apple-pay", label: "Apple Pay", kind: "wallet",
    available: canUseApplePay, unavailableReason: "Needs Safari on an Apple device" },
  { id: "pay-in-3", label: "Pay in 3", kind: "bnpl", instalments: 3 },
]

<PaymentSelector
  methods={methods}
  value={method}
  onValueChange={setMethod}
  total={order.total}                 // "3 payments of $259.20"
  error={paymentError}                // shown in the open panel
  // The provider slot. Panels stay mounted once opened, so the iframe
  // keeps a half-typed card while the shopper looks at another method.
  renderPanel={(m) => (m.id === "card" ? <PaymentElement /> : null)}
/>
Composed — your own row content
import { PaymentSelector, PaymentSelectorMethod, usePaymentSelector } from "@/components/ecomcn/payment-selector"

<PaymentSelector methods={methods} value={method} onValueChange={setMethod} renderPanel={renderPanel}>
  {methods.map((m) => (
    <PaymentSelectorMethod key={m.id} method={m}>
      {m.kind === "bnpl" ? <InstalmentBadge /> : null}
    </PaymentSelectorMethod>
  ))}
</PaymentSelector>

function InstalmentBadge() {
  const { formatMoney } = usePaymentSelector()
  return <span className="text-xs">from {formatMoney(30)}/mo</span>
}

Props & context

Read straight out of the source at build time, so this cannot drift from the file you install.

PaymentSelectorContextValue
methods: PaymentMethod[]
  /** The chosen method's id, or null before the shopper chooses. */
  value: string | null
  select: (id: string) => void
  selected: PaymentMethod | undefined
  /** Methods opened so far — their panels stay mounted while `keepMounted`. */
  opened: ReadonlySet<string>
  keepMounted: boolean
  renderPanel?: (method: PaymentMethod) => React.ReactNode
  /** A provider failure, shown in the chosen method's panel. */
  error: React.ReactNode
  /** "3 payments of $259.20", or null when the method or `total` doesn't say. */
  instalmentText: (method: PaymentMethod) => string | null
  formatMoney: (amount: number) => string
  /** Where a method sits in the list — rows and panels are placed by it. */
  indexOf: (id: string) => number
  panelId: (id: string) => string
  /** For the sliding rule's shared layout id. */
  scope: string
  disabled: boolean
PaymentSelectorProps
methods: PaymentMethod[]
  value: string | null
  onValueChange: (id: string) => void
  /** The provider slot. Return null for a method with nothing to show. */
  renderPanel?: (method: PaymentMethod) => React.ReactNode
  /** The order total, for instalment lines. */
  total?: number
  currency?: string
  /** `undefined` follows the visitor's locale, once hydrated. */
  locale?: string
  /** A provider failure — "Your card was declined" — shown in the open panel. */
  error?: React.ReactNode
  /** Keep a panel mounted once opened, so a provider's iframe keeps its state. Defaults to true. */
  keepMounted?: boolean
  /** "Payment", by default. */
  legend?: React.ReactNode
  /** Keeps the legend for screen readers when your page already has a heading. */
  hideLegend?: boolean
  /** The radio group's name, for a plain form post. */
  name?: string
  /** The methods aren't known yet. */
  loading?: boolean
  disabled?: boolean
  children?: React.ReactNode
PaymentSelectorMethodProps
method: PaymentMethod

Source

components/ecomcn/payment-selector.tsx
"use client"

import * as React from "react"
import { CircleAlert } from "lucide-react"
import { MotionConfig, motion, useReducedMotion } from "motion/react"

import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"

/*
 * How the shopper pays: one ruled list of methods — card, wallet, pay in
 * instalments, bank — where the chosen one opens onto your payment
 * provider's own fields.
 *
 *   <PaymentSelector
 *     methods={methods}
 *     value={method}
 *     onValueChange={setMethod}
 *     renderPanel={(m) => (m.id === "card" ? <PaymentElement /> : null)}
 *     total={777.6}
 *   />
 *
 * It never renders a card-number, expiry or CVC field. Each method's panel
 * is a slot — `renderPanel` — for Stripe's Payment Element, Adyen's Drop-in,
 * PayPal's buttons or a redirect notice. A panel stays mounted once opened
 * (`keepMounted`), hidden rather than destroyed, so a provider's iframe keeps
 * the half-typed card when the shopper looks at another method and comes
 * back. Panels sit outside the radio group, so the arrow keys move between
 * methods without ever reaching into a provider's fields — and laid out
 * under their own method, joined to it as one object. The heavy rule slides
 * to the chosen method and its panel grows to its measured height (Motion;
 * a fade under reduced motion).
 */

export interface PaymentMethod {
  id: string
  label: string
  /** "Visa, Mastercard, Amex". */
  description?: React.ReactNode
  /** Your brand marks, at the row's end. ecomcn ships none: they are trademarks. */
  icon?: React.ReactNode
  kind: "card" | "wallet" | "bnpl" | "bank" | "other"
  /** Pay in N: shows "3 payments of $259.20" from `total`. */
  instalments?: number
  /** False when it can't be used here — Apple Pay outside Safari, say. */
  available?: boolean
  /** Why, in words: "Needs Safari on an Apple device". */
  unavailableReason?: string
}

/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */
const MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const
const FADE = { duration: 0.15 } as const

const subscribeNothing = () => () => {}

/** Your locale, or the visitor's once hydrated — so server and client HTML agree. */
function useFormatLocale(locale?: string) {
  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)
  return locale ?? (hydrated ? undefined : "en-US")
}

/* ─── context ──────────────────────────────────────────────────────────── */

export interface PaymentSelectorContextValue {
  methods: PaymentMethod[]
  /** The chosen method's id, or null before the shopper chooses. */
  value: string | null
  select: (id: string) => void
  selected: PaymentMethod | undefined
  /** Methods opened so far — their panels stay mounted while `keepMounted`. */
  opened: ReadonlySet<string>
  keepMounted: boolean
  renderPanel?: (method: PaymentMethod) => React.ReactNode
  /** A provider failure, shown in the chosen method's panel. */
  error: React.ReactNode
  /** "3 payments of $259.20", or null when the method or `total` doesn't say. */
  instalmentText: (method: PaymentMethod) => string | null
  formatMoney: (amount: number) => string
  /** Where a method sits in the list — rows and panels are placed by it. */
  indexOf: (id: string) => number
  panelId: (id: string) => string
  /** For the sliding rule's shared layout id. */
  scope: string
  disabled: boolean
}

const PaymentSelectorContext = React.createContext<PaymentSelectorContextValue | null>(null)

export function usePaymentSelector() {
  const context = React.useContext(PaymentSelectorContext)
  if (!context) {
    throw new Error("usePaymentSelector must be used within <PaymentSelector>.")
  }
  return context
}

/* ─── root ─────────────────────────────────────────────────────────────── */

export interface PaymentSelectorProps
  extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, "onChange" | "defaultValue" | "name" | "children"> {
  methods: PaymentMethod[]
  value: string | null
  onValueChange: (id: string) => void
  /** The provider slot. Return null for a method with nothing to show. */
  renderPanel?: (method: PaymentMethod) => React.ReactNode
  /** The order total, for instalment lines. */
  total?: number
  currency?: string
  /** `undefined` follows the visitor's locale, once hydrated. */
  locale?: string
  /** A provider failure — "Your card was declined" — shown in the open panel. */
  error?: React.ReactNode
  /** Keep a panel mounted once opened, so a provider's iframe keeps its state. Defaults to true. */
  keepMounted?: boolean
  /** "Payment", by default. */
  legend?: React.ReactNode
  /** Keeps the legend for screen readers when your page already has a heading. */
  hideLegend?: boolean
  /** The radio group's name, for a plain form post. */
  name?: string
  /** The methods aren't known yet. */
  loading?: boolean
  disabled?: boolean
  children?: React.ReactNode
}

export function PaymentSelector({
  methods,
  value,
  onValueChange,
  renderPanel,
  total,
  currency = "USD",
  locale: localeProp,
  error = null,
  keepMounted = true,
  legend = "Payment",
  hideLegend = false,
  name,
  loading = false,
  disabled = false,
  className,
  children,
  ...props
}: PaymentSelectorProps) {
  const locale = useFormatLocale(localeProp)
  const uid = React.useId()
  const legendId = `${uid}-legend`

  // Every method opened so far; adjusted during render, so a panel mounts
  // in the same commit as its selection.
  const [opened, setOpened] = React.useState<ReadonlySet<string>>(() => new Set(value ? [value] : []))
  if (value && !opened.has(value)) setOpened(new Set([...opened, value]))

  const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency])
  const formatMoney = React.useCallback((amount: number) => money.format(amount), [money])
  const instalmentText = React.useCallback(
    (method: PaymentMethod) => {
      const n = method.instalments
      if (!n || n < 2 || total === undefined) return null
      return `${n} payments of ${money.format(Math.round((total / n) * 100) / 100)}`
    },
    [money, total]
  )

  const ids = methods.map((m) => m.id).join("\n")
  const indexOf = React.useCallback((id: string) => ids.split("\n").indexOf(id), [ids])
  const panelId = React.useCallback((id: string) => `${uid}-panel-${id}`, [uid])

  const context = React.useMemo<PaymentSelectorContextValue>(
    () => ({
      methods,
      value,
      select: onValueChange,
      selected: methods.find((m) => m.id === value),
      opened,
      keepMounted,
      renderPanel,
      error,
      instalmentText,
      formatMoney,
      indexOf,
      panelId,
      scope: uid,
      disabled,
    }),
    [methods, value, onValueChange, opened, keepMounted, renderPanel, error, instalmentText, formatMoney, indexOf, panelId, uid, disabled]
  )

  const rows = methods.length * 2
  return (
    <PaymentSelectorContext.Provider value={context}>
      <MotionConfig reducedMotion="user">
        <fieldset
          data-slot="payment-selector"
          aria-busy={loading || undefined}
          className={cn("min-w-0", className)}
          {...props}
        >
          <legend id={legendId} className={cn("mb-3 text-base font-medium", hideLegend && "sr-only")}>
            {legend}
          </legend>
          {loading ? (
            <PaymentSelectorSkeleton />
          ) : methods.length === 0 ? (
            <p data-slot="payment-selector-empty" className="border px-4 py-5 text-sm text-muted-foreground">
              No payment method can be used for this order.
            </p>
          ) : (
            // Methods and panels share one grid: the radio group spans it as
            // a subgrid holding the method rows (odd rows), and each panel is
            // placed in the row under its method (even rows) — outside the
            // group in the DOM, so arrow keys never reach a provider's field.
            // Both are pinned to column 1: left to auto-placement, a panel
            // would find column 1 taken by the group and open a second one.
            <div data-slot="payment-selector-list" className="grid grid-cols-1 border" style={{ gridTemplateRows: `repeat(${rows}, auto)` }}>
              <RadioGroup
                value={value ?? ""}
                onValueChange={(next: unknown) => onValueChange(String(next))}
                name={name}
                disabled={disabled}
                aria-labelledby={legendId}
                className="grid gap-0"
                style={{ gridColumn: 1, gridRow: `1 / span ${rows}`, gridTemplateRows: "subgrid" }}
              >
                {children ?? methods.map((method) => <PaymentSelectorMethod key={method.id} method={method} />)}
              </RadioGroup>
              {methods.map((method) => (
                <PaymentSelectorPanel key={method.id} method={method} />
              ))}
            </div>
          )}
        </fieldset>
      </MotionConfig>
    </PaymentSelectorContext.Provider>
  )
}

/* ─── parts ────────────────────────────────────────────────────────────── */

export interface PaymentSelectorMethodProps extends React.HTMLAttributes<HTMLLabelElement> {
  method: PaymentMethod
}

/** One method: the radio, its name, what it takes or costs, and your mark. */
export function PaymentSelectorMethod({ method, className, children, ...props }: PaymentSelectorMethodProps) {
  const { value, indexOf, panelId, instalmentText, scope, disabled } = usePaymentSelector()
  const reduce = useReducedMotion()
  const selected = value === method.id
  const unavailable = method.available === false
  const instalments = unavailable ? null : instalmentText(method)
  const noteId = `${panelId(method.id)}-note`
  const hasNote = unavailable || instalments || method.description

  return (
    <label
      data-slot="payment-selector-method"
      data-state={selected ? "checked" : "unchecked"}
      data-unavailable={unavailable || undefined}
      style={{ gridRow: indexOf(method.id) * 2 + 1 }}
      className={cn(
        "relative flex min-h-14 cursor-pointer items-center gap-4 border-t px-4 py-3.5 transition-colors first:border-t-0",
        selected ? "bg-secondary/50" : "hover:bg-secondary/30",
        (unavailable || disabled) && "cursor-not-allowed hover:bg-transparent",
        className
      )}
      {...props}
    >
      {selected ? (
        <motion.span
          aria-hidden
          layoutId={reduce ? undefined : `${scope}-rule`}
          transition={MORPH}
          className="absolute inset-y-0 -left-px w-0.5 bg-foreground"
        />
      ) : null}
      <RadioGroupItem
        value={method.id}
        disabled={unavailable || disabled}
        aria-controls={panelId(method.id)}
        aria-describedby={hasNote ? noteId : undefined}
      />
      <span className="flex min-w-0 flex-1 flex-col gap-0.5">
        <span className={cn("text-sm font-medium", unavailable && "text-muted-foreground line-through decoration-1")}>
          {method.label}
        </span>
        {hasNote ? (
          <span id={noteId} className="flex flex-col text-[13px] leading-snug text-muted-foreground">
            {unavailable ? (method.unavailableReason ?? "Not available for this order") : null}
            {!unavailable && instalments ? <span className="text-foreground tabular-nums">{instalments}</span> : null}
            {!unavailable && method.description ? <span>{method.description}</span> : null}
          </span>
        ) : null}
      </span>
      {method.icon ? (
        <span aria-hidden className={cn("flex shrink-0 items-center gap-1.5 text-muted-foreground", unavailable && "opacity-50")}>
          {method.icon}
        </span>
      ) : null}
      {children}
    </label>
  )
}

/**
 * The provider slot under one method. Mounted from the first time its
 * method is chosen; while another is chosen it folds to nothing and is
 * hidden, never unmounted (unless `keepMounted` is off).
 */
function PaymentSelectorPanel({ method }: { method: PaymentMethod }) {
  const { value, opened, keepMounted, renderPanel, error, indexOf, panelId } = usePaymentSelector()
  const reduce = useReducedMotion()
  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)
  const open = value === method.id
  if (!open && !(keepMounted && opened.has(method.id))) return null

  const body = renderPanel?.(method) ?? null
  const showError = open && error
  if (body === null && !showError) return null

  const shown = reduce
    ? { opacity: 1, height: "auto", visibility: "visible" as const }
    : { opacity: 1, height: "auto", visibility: "visible" as const, transitionEnd: { overflow: "visible" } }
  const folded = { opacity: 0, height: 0, overflow: "hidden", transitionEnd: { visibility: "hidden" as const } }

  return (
    <motion.div
      id={panelId(method.id)}
      data-slot="payment-selector-panel"
      data-method={method.id}
      data-state={open ? "open" : "closed"}
      aria-hidden={open ? undefined : true}
      style={{ gridColumn: 1, gridRow: indexOf(method.id) * 2 + 2 }}
      // Opened after load: grow in. Open in the first render: already there.
      initial={hydrated ? { opacity: 0, height: 0, overflow: "hidden" } : false}
      animate={open ? shown : folded}
      transition={reduce ? FADE : MORPH}
      className="relative min-w-0 bg-secondary/50"
    >
      <span aria-hidden className="absolute inset-y-0 -left-px w-0.5 bg-foreground" />
      <div className="px-4 pt-1 pb-5 sm:pl-12">
        {showError ? (
          <p role="alert" className="mb-3 flex items-start gap-2 text-sm text-destructive">
            <CircleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
            <span>{error}</span>
          </p>
        ) : null}
        {body}
      </div>
    </motion.div>
  )
}

/** Three ruled rows in the list's shape, while the methods load. */
function PaymentSelectorSkeleton() {
  return (
    <div data-slot="payment-selector-skeleton" className="border">
      <span className="sr-only" role="status">
        Loading payment methods
      </span>
      {[0, 1, 2].map((row) => (
        <div key={row} aria-hidden className="flex min-h-14 items-center gap-4 border-t px-4 py-3.5 first:border-t-0">
          <Skeleton className="size-4 rounded-full" />
          <span className="flex flex-1 flex-col gap-2">
            <Skeleton className="h-3.5 w-32 rounded-none" />
            <Skeleton className="h-3 w-48 rounded-none" />
          </span>
          <Skeleton className="h-5 w-10 rounded-none" />
        </div>
      ))}
    </div>
  )
}