Buttons

Payment Buttons

An express-checkout wallet row — availability decides what is offered at all, brand paint and marks arrive as props, and a dismissed sheet is never dressed up as a failure.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { LoaderCircle, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Brand paint for one wallet — the single documented exception to this library's
 * token-only rule, and the reason it is *data* rather than source.
 *
 * Apple Pay's black, Google Pay's white and PayPal's gold are trademarked assets
 * with published usage rules attached. They are not part of anybody's design
 * system, they must not follow the host theme, and a copy baked into a component
 * library goes stale the moment a wallet restyles it. So they arrive as plain CSS

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/payment-buttons.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PaymentButtons" component: the express
checkout row that sits above a card form (lucide-react for the spinner and the
alert glyph — no wallet SDK, no popover, no form library).

Contract
- forwardRef<HTMLDivElement, PaymentButtonsProps> extending
  React.HTMLAttributes<HTMLDivElement>; remaining props and className land on the
  root column and are merged with cn().
- wallets: {
    id: string,                 // unique: React key, in-flight key, error owner
    label: string,              // wallet name only — "Apple Pay"
    icon?: ReactNode,           // the wallet mark, supplied by the consumer
    labelHidden?: boolean,      // the mark already carries the wordmark
    onPay: () => void | Promise<void>,           // REQUIRED
    available?: boolean | (() => boolean | Promise<boolean>),
    brand?: { background?, foreground?, border? },
    guidelines?: { minHeight?, maxHeight?, maxRadius? },
    callToAction?: "buy" | "checkout" | "pay" | "book" | "donate" |
                   "subscribe" | "plain"
  }[]
- Call it `wallets`, and treat it as candidates rather than offers: what the
  visitor sees is whatever survives availability.
- onPay is required. A pay button that renders pressable and does nothing is the
  most expensive lie on a checkout page. Its return type is the whole in-flight
  protocol: a promise keeps this button busy and the rest of the row locked until
  the sheet settles; a void return is a synchronous handover (a redirect to the
  wallet's own site) and releases the guard at once.
- brand is the ONE documented exception to token-only styling, and it is data,
  not source: Apple Pay's black, Google Pay's white and PayPal's gold are
  trademarked assets with published usage rules, they must not follow the host
  theme, and a copy baked into a component library goes stale the moment a wallet
  restyles. Accept plain CSS colour strings — including var(--brand-applepay-bg),
  which is how a consumer gets a light and a dark brand surface without this
  component knowing about themes — and paint them inline. Hardcode no colour
  anywhere in the file. For the same trademark reason ship no marks: `icon` is a
  slot.
- guidelines is the same idea for geometry. Wallets publish presentation minimums
  and shipping outside them is a compliance problem, not a taste problem: at the
  time of writing Apple Pay asks for at least 30px of height, Google Pay for 40px
  with the corner radius capped at 20px (or a full pill), PayPal accepts 25–55px
  and allows a pill, Link asks for 40px. Do NOT bake those numbers into the
  component — they change on the wallet's schedule. Take them per wallet and
  clamp into them.
- Row-level props: callToAction (default "pay"), total?: string (a formatted
  amount, e.g. "$128.40"), buttonHeight (default 48), cornerRadius (default 8),
  layout?: "stack" | "grid" (default "stack"), dividerLabel (default
  "Or pay another way", null removes it), label (the group's accessible name),
  probeTimeoutMs (default 1500, 0 waits forever).
- Copy props, each interpolating "{wallet}": errorText, cancelText, successText,
  busyText — plus checkingText and emptyText for the two availability states.
- State the host may own: pendingId?: string | null and error?: { walletId?,
  message } | null. Each is controlled when the prop is passed (undefined = this
  component owns it, null = "explicitly nothing").
- Callbacks: onPendingChange(walletId | null) so the host can lock its own card
  form while a sheet is open; onPaymentError(error, walletId) which fires on
  every real failure, mounted or not; onWalletCancel(walletId), which is analytics
  and not an error path.
- Also export isPaymentCancellation(cause) and a PaymentCancelledError class, so
  a consumer wrapping an SDK can signal "the customer backed out" in the same
  vocabulary the platform already uses.
- total is appended to every accessible name and deliberately never painted: the
  wallet's own sheet is what states the amount, and a second copy on the button is
  a second thing that can go stale.

Behavior
- Availability is the first state, before anything is pressable. `available`
  omitted or true means always offered, false withdraws the wallet outright, and
  a function is a probe (window.ApplePaySession, PaymentRequest.canMakePayment())
  run ONCE per id in an effect after mount. Never during render: reading browser
  capability while rendering is a hydration mismatch, and the server has no
  wallets at all. Key the effect on the joined ids of the probed wallets — with
  the separator written as an escape sequence, never as a literal control byte —
  and store the answers together with that key, so a new wallet set is back to
  "checking" in the very render that introduces it rather than one render later.
- Fail closed. A probe that rejects, throws, or outlives probeTimeoutMs counts as
  unavailable. An offered wallet that cannot actually pay is a dead end at the
  last step of a checkout, and canMakePayment() genuinely hangs behind permission
  prompts and dead bridges. The timeout is one timer per probe, cleared on
  unmount and on every dependency change; whichever answer lands first wins.
- The row reveals as one. While ANY probe is open, render skeleton placeholders
  at each wallet's clamped height and nothing pressable — do not stream buttons
  in one by one. A pay button that slides under a cursor already moving towards
  "Pay by card" is the one mis-click on the page that costs money.
- When nothing survives, the row renders empty — no buttons, and above all no
  divider. A lone "Or pay another way" hanging over a card form is a promise of
  something that is not there. Keep the root element mounted anyway: it carries
  the live region, and it is the anchor for the focus handoff below.
- One press wins. A pendingRef is read AND written synchronously inside the click
  handler, before onPay can await: a double click fires two events in one tick,
  before React re-renders, so a state-only guard lets both through and opens two
  payment sheets. The ref is the gate; state is only paint. An attempt counter
  captured in the closure makes a superseded promise settle into nothing.
- In flight, exactly one button is aria-busy with the spinner swapped into its
  mark slot; every other button becomes aria-disabled — and aria-disabled ONLY.
  Never the native attribute and never pointer-events:none: the browser blurs a
  control the instant it is disabled, so locking the row while a keyboard user is
  standing on it drops focus to <body>. The guard lives in the handler, so a
  locked button stays hoverable, focusable and announced ("unavailable while
  Apple Pay is authorizing").
- Cancelling is not failing, and this is the whole difference between a payment
  row and a sign-in row. PaymentRequest.show() rejects with a DOMException named
  "AbortError" when the customer dismisses the sheet, and every wallet SDK has an
  equivalent; that is the most common outcome of a wallet flow. Detect it by name
  and render a muted, non-alarming line — which may honestly say "Nothing was
  charged", because a dismissed sheet never authorized anything. Any other
  rejection is a real failure: role="alert", text-destructive, and copy that must
  NOT claim nothing was charged, because a failure can happen after authorization
  and only the server knows which side of it you are on.
- Both outcomes put focus back on the button that owns them, so the retry is one
  keystroke — but only if document.activeElement is still inside the root (or is
  <body>). Yanking focus away from whatever the visitor moved on to is hostile.
- Focus when the row changes shape: if the offered set shrinks while focus was
  inside it, hand focus to a deliberate successor instead of letting the browser
  drop it on <body> — the neighbouring wallet by index when one is left, the
  first focusable element after the root in document order (which on a checkout
  page is the card form the divider pointed at) when the row empties, and the
  root itself as the last resort, so it needs tabIndex={-1}. Track "focus is
  inside me" with a focusout handler that only lowers the flag for a relatedTarget
  outside the root: a null relatedTarget is exactly the removal case you are
  trying to catch.
- Announcements: one permanently mounted sr-only role="status" carries, in
  priority order, the busy text, the cancel/success line, the checking text, and
  the "nothing available" text. Permanently mounted matters — a live region
  inserted at the same moment as its text is unreliable in most screen readers —
  and it is the only announcer, so the visible cancel line is aria-hidden while
  still being referenced by the owning button's aria-describedby (aria-hidden
  content referenced that way is still used for the description).
- Keyboard map: deliberately native. Each wallet is its own Tab stop, Enter and
  Space activate it, and there is no roving tabindex and no arrow-key handling. A
  radiogroup or toolbar pattern would be wrong: nothing is "selected", these are
  N independent ways to pay. The discipline is in what stays focusable while
  locked, not in intercepting keys.
- Degenerate cases: wallets=[] renders an empty root and says nothing (the host
  never had candidates, so there is nothing to report); a pendingId pointing at
  an id that is not in the list still locks the row and still says so; a wallet
  with no mark simply has no slot, so nothing shifts; duplicate ids are the
  consumer's bug (they are React keys).
- Cleanup: probe timers are cleared on unmount and on dependency change and their
  effect flips a live flag so a late answer is dropped; a mountedRef (re-armed in
  the effect body, not just initialised, because StrictMode mounts twice) guards
  every setState behind an await; promise handlers check the attempt token first.

Rendering & styling
- Everything except `brand` is a semantic token: bg-primary /
  text-primary-foreground for a wallet with no brand paint, bg-muted for the
  loading placeholders, text-muted-foreground for the divider and the cancel
  line, text-destructive for the alert, bg-border for the divider rules, ring-ring
  for focus. Tokens are also how the row gets dark mode for free — the only
  theme-independent pixels on it are the ones the consumer paid for with `brand`.
- ARIA skeleton: root column (consumer className, remaining props, tabIndex={-1})
  > either an aria-hidden skeleton row or a div role="group" aria-label={label}
  holding the buttons > the alert paragraph > the aria-hidden cancel line > the
  divider > a permanently mounted sr-only role="status". role="group", not
  radiogroup/list: nothing here is selected.
- Accessible name per button = "{verb} {label}" + ", total {total}" + one of
  ", authorizing" / ", unavailable while {pending} is authorizing". The visible
  label is the prefix of that string (WCAG 2.5.3), so voice control still
  activates "Pay with Apple Pay"; with labelHidden the name is unchanged and only
  the duplicated pixels go away.
- Height and radius are inline styles, because they are clamped data rather than
  design decisions: height = clamp(buttonHeight, minHeight, maxHeight), radius =
  min(cornerRadius, maxRadius, height / 2) — past half the height a radius is not
  a rounder corner, it is a pill. One row can therefore legitimately hold buttons
  of different heights.
- A brand surface cannot take hover tokens without losing the brand, and a filter
  is no help either — brightness() can neither lighten black nor darken white. Use
  a currentColor state layer: an absolutely positioned, pointer-events-none
  ::before with before:bg-current and before:rounded-[inherit] (so it follows the
  clamped radius), opacity 0 → 0.1 on hover and back to 0 while aria-disabled. It
  is the button's own ink, so it reads as a shade over a dark brand and a tint
  over a light one. The verb, the mark slot and the label are position:relative so
  they paint above that layer — otherwise a hover would tint a trademarked mark.
- The focus ring uses ring-offset-2 ring-offset-background so it reads against a
  surface this component knows nothing about.
- layout="stack": full-width rows, centred content, mark then label. layout="grid":
  auto-fit tiles via grid-template-columns: repeat(auto-fit, minmax(min(11rem,
  100%), 1fr)). The mark slot is a fixed size-5 box rather than a proportional
  one, because it still has to fit inside a 25px-tall PayPal button.
- Reduced motion: the only motion is the spinner (motion-reduce:animate-none), the
  placeholder pulse (same), the colour transition and the state layer's fade (both
  motion-reduce:transition-none). With motion off the row still probes, still
  locks and still announces: aria-busy and the live region carry the state, not
  the animation.

Customization levers
- Copy and locale: callToAction picks the verb per row and per wallet ("plain"
  renders the wallet name alone, which is what an icon-led row wants);
  dividerLabel (null removes the divider entirely, e.g. when the row sits under
  the card form instead of above it); errorText / cancelText / successText /
  busyText / checkingText / emptyText all interpolate {wallet} where it applies.
- Geometry: buttonHeight and cornerRadius are the two numbers to touch — 40/8 for
  a compact drawer, 56/28 for a comfortable touch row, cornerRadius={999} for
  pills wherever a wallet allows one. Every value is a request that guidelines
  clamp, so raising the row's height can never push a wallet out of compliance.
- Order and count: wallets is data. Two entries and no divider is a cart drawer;
  four plus a divider is a full checkout. Keep the order stable instead of
  promoting the last used wallet — a row that reshuffles under the cursor is how
  people pay from the wrong account.
- Skins: pass no `brand` at all for a row that is pure host tokens, light and dark
  (the right default while you are still waiting on brand assets); pass `brand`
  per wallet to follow the official guidelines; pass CSS variables as the brand
  values when the brand itself has a dark variant.
- Ownership: leave pendingId and error off for the self-driving component, or pass
  both when your payment layer owns the round trip. Wire onPendingChange into
  whatever else can charge the customer — the card form's submit button above all
  — so two charges can never be in flight at once.
- Availability: omit `available` entirely for wallets you always offer, or push
  the probe as deep as your SDK allows. Raise probeTimeoutMs on a slow bridge, and
  set 0 only if you would rather show placeholders forever than drop a wallet.

Concepts

  • Offered, not listed — a wallet is a candidate until the device says otherwise. Probes run once after mount, never during render (the server has no wallets, so reading capability while rendering is a hydration mismatch), and anything that rejects, throws or outlives its timeout counts as unavailable: an offered wallet that cannot pay is a dead end at the last step of a checkout.
  • The row reveals as one — while any probe is open the whole row is placeholders and nothing is pressable, because a pay button that streams in under a cursor already moving towards "Pay by card" is the single mis-click on the page that costs money. When nothing survives, the row disappears whole — including the divider, since a lone "Or pay another way" is a promise of something that is not there — and hands focus to the card form below rather than dropping it on <body>.
  • Cancelling is not failing — a dismissed sheet is the most common outcome of a wallet flow, and it arrives as an AbortError. It gets a quiet muted line that can honestly say nothing was charged; only a real rejection gets role="alert", and its copy deliberately does not make that claim, because a failure can happen after authorization.
  • One press wins — the in-flight gate is a ref read and written inside the handler, before onPay can await. A double click fires two events in one tick, before React re-renders, so a state-only guard opens two payment sheets; an attempt counter additionally makes a superseded promise settle into nothing.
  • Locking without disabling — while one sheet is open the other wallets go aria-disabled, never native disabled and never pointer-events:none. The browser blurs a control the instant it is disabled, so the native attribute would drop a keyboard user on <body> mid-payment; here the guard lives in the handler and the buttons stay focusable and announced.
  • Brand paint and brand geometry are both data — the row is token-only everywhere except two props. Colours and marks arrive from outside because they are trademarked assets that must not follow the host theme; published height and radius bounds arrive from outside because they change on the wallet's schedule, and the row's own buttonHeight / cornerRadius are requests that each button clamps into compliance.

On This Page