Customize your checkout
Apply consistent themes and add automatic payment buttons for focused purchase flows.
Customize your checkout
Use checkout themes to match your product experience, then add automatic payment buttons when a buyer should complete a focused purchase without opening a full checkout first.
Who this is for
This guide is for developers and designers who need consistent checkout styling, reusable theme bundles, or a compact payment action for upgrades, add-ons, and saved-payment-method flows.
Choose the checkout surface
Start with FloPayCheckout when buyers need to review or enter payment details. Use FloPayAutomaticPaymentButton for a focused action that can charge a saved method and fall back to checkout when authentication or new details are required. The same theme can cover both surfaces.
Apply a checkout theme
FloPay ships six first-class theme bundles plus a 'classic' no-op marker. A single theme value on FloPayCheckout, FloPayAutomaticPaymentButton, or SplitCardForm paints both sides of the integration in one go:
- the Stripe-side appearance (palette + nested-input
rules) - the React-rendered wrapper, title, submit button, and inputs
- on
FloPayAutomaticPaymentButton, the fallbackFloPayCheckoutmodal that opens when the saved-payment charge needs user interaction
Build a theme visually in the theme editor on our playground, then drop the resulting theme value (or copy custom appearance / buttonsStyles overrides) into your app.
Theme Bundles
theme value | ThemeBundleId | Aesthetic |
|---|---|---|
'modern-light' | ✓ | Clean & airy: Inter, soft shadows, generous spacing, FloPay-blue accents |
'modern-dark' | ✓ | Modern light with a dark surface |
'bold-light' | ✓ | Saturated FloPay blue, gradient pill submit, heavy borders |
'bold-dark' | ✓ | Bold light on a dark surface |
'glass-light' | ✓ | Translucent surfaces with backdrop blur on a blue gradient |
'glass-dark' | ✓ | Glass light on a dark surface |
'classic' | Not applicable | Marker that preserves the historic FloPay look (#EDEDFF wrapper, #4A49FF indigo submit) so consumers can declare a default explicitly |
All bundles are built around FloPay blue: #1785E0 on light bundles, #60A5FA on dark bundles for contrast.
One Prop, Everywhere
import { FloPayCheckout, FloPayAutomaticPaymentButton } from '@flopay/react';
// Same look across both components
<FloPayCheckout theme="bold-dark" sessionId={sessionId} onComplete={handleSuccess} />
<FloPayAutomaticPaymentButton theme="bold-dark" sessionId={upsellSessionId} onSuccess={handleSuccess}>
Add upsell
</FloPayAutomaticPaymentButton>SplitCardForm accepts the same theme prop when you want to render it manually inside FloPayProvider.
Resolution Precedence
When several styling props are present, the SDK resolves them from highest priority to lowest:
- Explicit
appearance: overrides the theme bundle's appearance (palette + Striperules). - Explicit
buttonsStyles: merges per-field on top of the theme bundle's button-layout styles. themebundle.- Legacy
buttonsThemepreset (back-compat for older integrations). - Hardcoded defaults.
You can drop a theme on the component and surgically override only the fields you need:
<FloPayCheckout
sessionId={sessionId}
theme="modern-light"
buttonsStyles={{
submitButton: { backgroundColor: '#0F172A' },
}}
/><FloPayCheckout
sessionId={sessionId}
theme="glass-dark"
appearance={{
variables: { colorPrimary: '#22D3EE' },
rules: { '.Input': { border: '1px solid rgba(255,255,255,0.3)' } },
}}
/>Reading A Bundle From @flopay/shared
The bundle map is exported so you can render swatches, preview cards, or merge an appearance into another system without re-implementing the look:
import {
THEMES,
resolveTheme,
MODERN_LIGHT_APPEARANCE,
BUTTONS_LAYOUT_MODERN_LIGHT,
} from '@flopay/shared';
const glassDark = THEMES['glass-dark'];
// { appearance: FloPayAppearance, buttonsLayout: ButtonsLayoutStyles }
const maybeBundle = resolveTheme('bold-light');
if (maybeBundle) {
console.log(maybeBundle.appearance.variables?.colorPrimary);
}
// Direct constant access: handy when you want to merge two themes
const merged = {
...MODERN_LIGHT_APPEARANCE,
variables: { ...MODERN_LIGHT_APPEARANCE.variables, colorPrimary: '#FF7A1A' },
};resolveTheme('classic') returns undefined: 'classic' is a marker, not a bundle, so the SDK falls through to its hardcoded defaults.
Runtime Theme Swapping
StripeAdapter.getElements() detects appearance changes between calls and live-updates the cached Stripe Elements group via elements.update({ appearance }). That makes runtime theme swaps actually re-style mounted card iframes:
const [mode, setMode] = useState<'light' | 'dark'>('light');
<FloPayCheckout
sessionId={sessionId}
theme={mode === 'dark' ? 'modern-dark' : 'modern-light'}
onComplete={handleSuccess}
/>Previously the Stripe iframe kept its stale styling because the elements group was cached. With the new adapter behavior, swapping theme (or appearance) at runtime takes effect on mounted inputs.
Appearance Derivation
When you pass appearance without a matching buttonsStyles, SplitCardForm now derives its React-side styling from appearance.variables:
- wrapper background falls back to
appearance.variables.colorBackground(the SDK default#FFFFFFis skipped so the historic#EDEDFFtint is preserved for'classic') - submit-button background falls back to
appearance.colorPrimary - title color, input color, border radius, and font family all derive from
appearance.variables
In practice, passing a custom appearance alone now produces a visibly themed form across layout="default" and layout="buttons".
Theme Variables Reference
| Variable | Type | Description |
|---|---|---|
colorPrimary | CSS color | Buttons, links, focus rings |
colorBackground | CSS color | Element backgrounds |
colorText | CSS color | Labels and input text |
colorDanger | CSS color | Error messages and invalid states |
borderRadius | CSS length | Corner rounding for inputs and buttons |
fontFamily | CSS font-family | Font stack for all elements |
fontSizeBase | CSS length | Base font size |
spacingUnit | CSS length | Base spacing multiplier |
appearance.rules accepts CSS-like blocks keyed by Stripe Element selectors (.Input, .Input:focus, .Label, .Tab, .Tab--selected) for fine-grained styling inside the iframe.
Buttons Layout Slots
When you use FloPayCheckout with layout="buttons", the expanded card form supports content slots for the card button, back button label, and title:
<FloPayCheckout
sessionId={sessionId}
layout="buttons"
cardButtonContent="Pay by card"
cardBackButtonContent=""
cardTitleContent="Enter card details"
onComplete={handleSuccess}
/>Pass any ReactNode to these props. Passing '' removes the text completely. The demo /theme playground includes controls for these slot props alongside theme and buttonsStyles.
Migrating From buttonsTheme
Legacy values still resolve via resolveButtonsLayoutTheme() for back-compat. Older integrations keep working untouched: the prop is silently ignored if theme is also supplied: but new code should use theme:
| Old | New |
|---|---|
buttonsTheme="default" | theme="classic" |
buttonsTheme="minimal" | theme="modern-light" |
buttonsTheme="rounded" | theme="modern-light" or theme="bold-light" |
buttonsTheme="dark" | theme="bold-dark" |
See Also
- Custom Theme example: side-by-side bundles plus override snippets
- Shared constants reference: every
*_APPEARANCE,BUTTONS_LAYOUT_*, andTHEMESexport - Shared types reference:
ThemeBundleId,ThemeId,ThemeBundle
Add automatic payment buttons
FloPayAutomaticPaymentButton turns FloPay's backend-driven auto checkout mode into a client component you can place anywhere in your UI. It is designed for upsells, add-ons, renewals, and other saved-payment journeys where you want a single button instead of a full checkout form.
The button keeps the user on the current page while still giving you FloPay's shared processing, success, and decline modal states.
When To Use It
Use FloPayAutomaticPaymentButton when you want to:
- trigger a saved-payment purchase from a success page or account area
- pass the same session-creation data you would normally post to your backend
- reuse an existing
sessionIdcreated elsewhere - style the button with the same theme system as the embedded buttons layout
- receive explicit
onClick,onSuccess,onError, andonDeclineevents
Basic Example
'use client';
import { FloPayAutomaticPaymentButton } from '@flopay/react';
export function UpsellButton() {
return (
<FloPayAutomaticPaymentButton
clientId="client_123"
currency="USD"
account={{
userId: 'user_123',
email: 'customer@example.com',
firstName: 'Jane',
lastName: 'Doe',
}}
products={[{ code: 'upsell_ai_pack' }]}
successUrl={`${window.location.origin}/success`}
cancelUrl={`${window.location.origin}/success`}
theme="bold-dark"
onClick={() => {
console.log('automatic payment button clicked');
}}
onSuccess={({ result, sessionId }) => {
console.log('payment succeeded', result.status, sessionId);
}}
onError={(error) => {
console.error(error.message);
}}
onDecline={(decline) => {
console.log('payment declined', decline.code);
}}
>
Purchase Item
</FloPayAutomaticPaymentButton>
);
}Session Sources
You can render the button from either input shape:
sessionId(plus itsnonce) when your backend already created the checkout session- inline session data via
createSession - direct convenience props for inline creation:
clientId,currency,products,account,successUrl,cancelUrl,couponCodes,tagsData,utmMetadata
The component always forces checkoutMode="auto" under the hood, so it uses the saved payment method attached to the session.
The backend resolves the customer's latest vaulted payment method (getLatestByUserId) and rebinds the session's gateway when the saved PM lives on a different provider than routing originally picked. You no longer pass paymentMethodId or checkoutMethod from the client: the backend orchestrates gateway selection and tokenizedData shape per provider ({ id: vaultToken } for Stripe, { userPaymentMethodId: uuid } for PayPal).
Reuse An Existing Session
Pass the session's nonce alongside sessionId. Your backend returns it on the create response (see Checkout Session Token); the SDK forwards it as the x-checkout-session-token header on the session read and /process. Without it, current backends reject both with 401 "Missing checkout session token.".
<FloPayAutomaticPaymentButton
sessionId="sess_abc123"
nonce="nonce_abc123"
onSuccess={() => window.location.reload()}
>
Purchase Item
</FloPayAutomaticPaymentButton>On the inline-creation path (createSession or the convenience props) you do not pass a nonce: the SDK mints it from the create response and threads it through every continuation call, including the fallback FloPayCheckout modal that opens when the charge needs extra authentication.
Create The Session Inline
<FloPayAutomaticPaymentButton
createSession={{
clientId: 'client_123',
currency: 'USD',
account: {
userId: 'user_123',
email: 'customer@example.com',
},
products: [{ code: 'upsell_ai_pack' }],
successUrl: `${window.location.origin}/success`,
cancelUrl: `${window.location.origin}/success`,
}}
>
Purchase Item
</FloPayAutomaticPaymentButton>Do not pass both sessionId and inline session-creation props at the same time.
Styling And Slot Content
The button shares the same theme system as FloPayCheckout. One theme value styles the button and the fallback FloPayCheckout modal that opens when the saved-payment charge needs user interaction:
themeaccepts anyThemeId('classic' | 'modern-light' | 'modern-dark' | 'bold-light' | 'bold-dark' | 'glass-light' | 'glass-dark')appearanceandbuttonsStylesare still available as per-field overrides on top of the theme bundlechildrenrender inside the button, so you can treat them as the button slot content
<FloPayAutomaticPaymentButton
sessionId="sess_abc123"
theme="bold-dark"
buttonsStyles={{
cardButtonFontSize: '1rem',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<strong>Add AI Supercharger Pack</strong>
<span style={{ opacity: 0.72 }}>$0.80 today</span>
</span>
</FloPayAutomaticPaymentButton>If children are omitted, the default embedded button content is rendered for you. See the Theming guide for the resolution precedence (appearance > buttonsStyles > theme > legacy buttonsTheme > defaults).
Events
| Prop | Purpose |
|---|---|
onClick | Fires when the button is pressed, before the automatic payment flow begins |
onSuccess | Fires after the success modal state completes; includes the payment result and resolved session details |
onError | Fires when the automatic payment flow fails with a FloPayError |
onDecline | Fires when FloPay can classify the failure as a decline or cancellation event |
FloPayAutomaticPaymentButton also extends the standard React button props, so disabled, className, aria-*, and similar attributes work as expected.
Authentication And Redirect Handling
Automatic Payment Buttons do not hard-fail when extra authentication is needed:
- when the saved payment method can complete immediately, the button shows the shared processing and success modal states
- when the payment provider returns a redirect-capable auth flow such as 3DS or PayPal, the backend resumes and captures the result end-to-end: there is no longer a client-side
payment_intent_client_secretURL-param watcher or sessionStorage handshake to wire up - when FloPay returns
authentication_requiredwithout a direct redirect token, the button opens an inlineFloPayCheckoutmodal on the same page so the user can finish authentication without navigating away; that fallback modal inherits the samethemeyou passed to the button
This makes the component a better fit for upsells than raw backend-only auto mode.
See Also
- Cascading Processing: how auto-checkout cascades across a returning customer's saved payment methods behind
autocheckout - Automatic Payment Buttons Example
- FloPayCheckout Guide
- Theming Guide
Complete theme example
FloPay ships six first-class theme bundles plus a 'classic' no-op marker. Pass a single theme value to FloPayCheckout, FloPayAutomaticPaymentButton, or SplitCardForm and the SDK styles both the Stripe-rendered fields and the React-rendered wrapper, submit button, and inputs from the same source.
Design your theme visually in the Theme Playground. Pick a bundle, tweak overrides, and copy the props straight into your code.
Bundled Themes
theme value | Aesthetic |
|---|---|
'modern-light' / 'modern-dark' | Clean & airy: Inter, soft shadows, generous spacing, FloPay-blue accents |
'bold-light' / 'bold-dark' | Saturated FloPay blue with a gradient pill submit and heavy borders |
'glass-light' / 'glass-dark' | Translucent surfaces with backdrop blur over a blue gradient |
'classic' | No-op marker that preserves the historic FloPay look (#EDEDFF wrapper, #4A49FF indigo submit) |
All bundles are built around FloPay blue (#1785E0 on light bundles, #60A5FA on dark bundles).
One Prop, Consistent Look
'use client';
import { FloPayCheckout } from '@flopay/react';
export default function ThemedCheckoutPage() {
return (
<FloPayCheckout
sessionId="your-session-uuid"
theme="bold-dark"
onComplete={(result) => console.log('Paid!', result)}
/>
);
}The same theme value styles the Stripe Elements inside the iframe and the React-rendered wrapper, title, submit button, and name input. No second prop to keep in sync.
Switching Bundles Side-By-Side
'use client';
import { FloPayCheckout } from '@flopay/react';
export function ThemeGallery({ sessionId }: { sessionId: string }) {
return (
<div
style={{
display: 'grid',
gap: '1.5rem',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
}}
>
<section>
<h3>Modern Light</h3>
<FloPayCheckout sessionId={sessionId} theme="modern-light" />
</section>
<section>
<h3>Bold Dark</h3>
<FloPayCheckout sessionId={sessionId} theme="bold-dark" />
</section>
<section>
<h3>Glass Dark</h3>
<FloPayCheckout sessionId={sessionId} theme="glass-dark" />
</section>
</div>
);
}Resolution Precedence
When multiple styling props are present, the SDK resolves them from highest priority to lowest:
- Explicit
appearance: overrides the theme's appearance (palette + Striperules). - Explicit
buttonsStyles: merges per-field on top of the theme's button layout. themebundle.- Legacy
buttonsThemepreset (back-compat for older integrations). - Hardcoded defaults.
This means you can drop a theme onto the component and then surgically override just the fields you need:
<FloPayCheckout
sessionId={sessionId}
theme="modern-light"
buttonsStyles={{
submitButton: { backgroundColor: '#0F172A' },
}}
/><FloPayCheckout
sessionId={sessionId}
theme="glass-dark"
appearance={{
rules: {
'.Input': { border: '1px solid rgba(255,255,255,0.3)' },
},
}}
/>Picking A Bundle Programmatically
Import the bundle map from @flopay/shared if you need to reference the appearance or button-layout directly (for example, to render a preview swatch outside the SDK):
import { THEMES, resolveTheme } from '@flopay/shared';
const glassDark = THEMES['glass-dark'];
// { appearance: FloPayAppearance, buttonsLayout: ButtonsLayoutStyles }
const bundle = resolveTheme('bold-light');
if (bundle) {
console.log(bundle.appearance.variables?.colorPrimary);
}resolveTheme('classic') returns undefined: 'classic' is a marker, not a bundle, so the SDK falls through to its hardcoded defaults.
Migrating From buttonsTheme
Legacy values still resolve via the back-compat shim, but new code should use theme:
| Old | New |
|---|---|
buttonsTheme="default" | theme="classic" |
buttonsTheme="minimal" | theme="modern-light" |
buttonsTheme="rounded" | theme="modern-light" (rounded corners) or theme="bold-light" |
buttonsTheme="dark" | theme="bold-dark" |
buttonsTheme is silently ignored when theme is supplied alongside it, so a single-line swap is safe.
See Also
- Theming guide: full reference for
theme,appearance, andbuttonsStyles - Shared constants reference: every
*_APPEARANCE,BUTTONS_LAYOUT_*, andTHEMESexport
Complete automatic payment button example
This example shows a same-page upsell on a success screen. The user keeps their original purchase details in view, clicks a single saved-payment button, and successful upsells are appended back into the page state.
'use client';
import { useMemo, useState } from 'react';
import { FloPayAutomaticPaymentButton } from '@flopay/react';
type PurchaseEntry = {
name: string;
amount: number;
currency: string;
};
const upsellProduct = {
code: 'upsell_ai_pack',
name: 'AI Supercharger Pack',
totalAmount: 249,
quantity: 1,
};
const upsellCurrency = 'USD';
function formatAmount(amount: number, currency: string) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
export default function UpsellSection() {
const [upsells, setUpsells] = useState<PurchaseEntry[]>([]);
const buttonLabel = useMemo(() => {
const amount = upsellProduct.totalAmount;
return `Add ${upsellProduct.name} for ${formatAmount(amount, upsellCurrency)}`;
}, []);
return (
<section style={{ display: 'grid', gap: '1rem' }}>
<div>
<h2>Original Purchase</h2>
<p>Core Plan - {formatAmount(49, 'USD')}</p>
</div>
<div style={{ display: 'grid', gap: '0.5rem' }}>
{upsells.map((item, index) => (
<p key={`${item.name}-${index}`}>
Upsell purchase {index + 1}: {item.name} - {formatAmount(item.amount, item.currency)}
</p>
))}
</div>
<FloPayAutomaticPaymentButton
clientId="client_123"
currency={upsellCurrency}
account={{
userId: 'user_123',
email: 'customer@example.com',
}}
products={[upsellProduct]}
successUrl={`${window.location.origin}/success`}
cancelUrl={`${window.location.origin}/success`}
theme="bold-dark"
onSuccess={() => {
setUpsells((current) => [
...current,
{
name: upsellProduct.name,
amount: upsellProduct.totalAmount,
currency: upsellCurrency,
},
]);
}}
onDecline={(decline) => {
console.log('payment declined', decline.code);
}}
onError={(error) => {
console.error(error.message);
}}
>
{buttonLabel}
</FloPayAutomaticPaymentButton>
</section>
);
}Why This Pattern Works
- the page stays on the original success route instead of redirecting to a separate checkout screen
- the button gets FloPay's shared processing, success, and decline modal states automatically
- if authentication is required, the SDK opens the fallback
FloPayCheckoutmodal on the same page using the sametheme, so the upsell button and its recovery modal stay visually consistent - successful upsells can update your local React state immediately
Backend Picks The Saved Payment Method
FloPayAutomaticPaymentButton no longer needs paymentMethodId or checkoutMethod. The backend's auto-checkout branch now:
- resolves the customer's latest vaulted payment method via
userPaymentMethodRepository.getLatestByUserId(userUuid) - rebinds the session's gateway when the latest payment method lives on a different provider than routing originally picked (fixes
payment_method_not_foundon cross-gateway saved-PM upsells) - builds the correct
tokenizedDatashape per provider ({ id: vaultToken }for Stripe,{ userPaymentMethodId: uuid }for PayPal)
Pass only the customer identity (account.userId) and the upsell line items; the backend takes care of the rest.
Reusing A Session Created On Your Backend
If your backend creates the upsell session first, switch the button to sessionId mode. Forward the session's nonce alongside it: your backend returns it on the create response, and the SDK needs it to authenticate the session read and /process (omit it and current backends return 401 "Missing checkout session token."). The same theme still propagates to the fallback modal if authentication is required:
<FloPayAutomaticPaymentButton
sessionId={upsellSessionId}
nonce={upsellSessionNonce}
theme="bold-dark"
onSuccess={() => {
setUpsells((current) => [
...current,
{
name: 'AI Supercharger Pack',
amount: 80,
currency: 'USD',
},
]);
}}
>
Purchase Item
</FloPayAutomaticPaymentButton>