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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/payment-buttons.jsonPrompt
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 getsrole="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
onPaycan 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 nativedisabledand neverpointer-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/cornerRadiusare requests that each button clamps into compliance.
Oauth Buttons
A third-party sign-in row whose brand colours arrive as props — one provider goes in flight, the rest lock without ever going natively disabled, and the last-used one stays marked.
Print Button
A print trigger that prepares the page before the dialog opens — a cancellable async hook, a print scope class, and a restore raced between afterprint, the print media query and a timeout.