Documentation
Guides

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 fallback FloPayCheckout modal 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 valueThemeBundleIdAesthetic
'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 applicableMarker 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:

  1. Explicit appearance: overrides the theme bundle's appearance (palette + Stripe rules).
  2. Explicit buttonsStyles: merges per-field on top of the theme bundle's button-layout styles.
  3. theme bundle.
  4. Legacy buttonsTheme preset (back-compat for older integrations).
  5. Hardcoded defaults.

You can drop a theme on the component and surgically override only the fields you need:

Override the submit color on top of a theme
<FloPayCheckout
  sessionId={sessionId}
  theme="modern-light"
  buttonsStyles={{
    submitButton: { backgroundColor: '#0F172A' },
  }}
/>
Override the Stripe Elements palette on top of a theme
<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 #FFFFFF is skipped so the historic #EDEDFF tint 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

VariableTypeDescription
colorPrimaryCSS colorButtons, links, focus rings
colorBackgroundCSS colorElement backgrounds
colorTextCSS colorLabels and input text
colorDangerCSS colorError messages and invalid states
borderRadiusCSS lengthCorner rounding for inputs and buttons
fontFamilyCSS font-familyFont stack for all elements
fontSizeBaseCSS lengthBase font size
spacingUnitCSS lengthBase 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:

OldNew
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

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 sessionId created elsewhere
  • style the button with the same theme system as the embedded buttons layout
  • receive explicit onClick, onSuccess, onError, and onDecline events

Basic Example

app/success/UpsellButton.tsx
'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 its nonce) 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:

  • theme accepts any ThemeId ('classic' | 'modern-light' | 'modern-dark' | 'bold-light' | 'bold-dark' | 'glass-light' | 'glass-dark')
  • appearance and buttonsStyles are still available as per-field overrides on top of the theme bundle
  • children render 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

PropPurpose
onClickFires when the button is pressed, before the automatic payment flow begins
onSuccessFires after the success modal state completes; includes the payment result and resolved session details
onErrorFires when the automatic payment flow fails with a FloPayError
onDeclineFires 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_secret URL-param watcher or sessionStorage handshake to wire up
  • when FloPay returns authentication_required without a direct redirect token, the button opens an inline FloPayCheckout modal on the same page so the user can finish authentication without navigating away; that fallback modal inherits the same theme you passed to the button

This makes the component a better fit for upsells than raw backend-only auto mode.

See Also

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 valueAesthetic
'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

app/checkout/page.tsx
'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

app/checkout/ThemeGallery.tsx
'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:

  1. Explicit appearance: overrides the theme's appearance (palette + Stripe rules).
  2. Explicit buttonsStyles: merges per-field on top of the theme's button layout.
  3. theme bundle.
  4. Legacy buttonsTheme preset (back-compat for older integrations).
  5. Hardcoded defaults.

This means you can drop a theme onto the component and then surgically override just the fields you need:

Override the submit color on top of a theme
<FloPayCheckout
  sessionId={sessionId}
  theme="modern-light"
  buttonsStyles={{
    submitButton: { backgroundColor: '#0F172A' },
  }}
/>
Override Stripe Element rules on top of a theme
<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):

Reading a theme bundle
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:

OldNew
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

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.

app/success/UpsellSection.tsx
'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 FloPayCheckout modal on the same page using the same theme, 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_found on cross-gateway saved-PM upsells)
  • builds the correct tokenizedData shape 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>

On this page