Components
The SDK’s components fall into three groups: commerce (<Checkout>, <CheckoutButton>, <UpsellButton>, <CheckoutResume>), flow (<Choice>, <Next>, <Back>), and chrome (<Sheet> / <Modal> with the modal registry, toast, <Script>). All import from @appfunnel-dev/sdk. The flow and chrome components are deliberately thin and restyleable — they pass className and DOM props straight through and dictate no styling beyond the minimum, so you can eject to the underlying hooks any time.
Commerce
Three components take money, and the one you write says where the payment happens:
import { Checkout, CheckoutButton, UpsellButton } from '@appfunnel-dev/sdk'
<Checkout offering="plan" /> // payment renders here
<CheckoutButton offering="plan">Get my plan</CheckoutButton> // a button opens it
<UpsellButton offering="addon">Add it</UpsellButton> // one tap, card already savedAll three take the offering slot id, handle the purchase analytics (checkout.start, purchase.complete) and advance the funnel after the charge. Card entry and the charge run through the store the funnel is published to — none of them names a provider.
Shared props
| Prop | Type | On | Behavior |
|---|---|---|---|
offering | string | all three | The offering slot id. Required. |
payment | 'auto' | 'wallets' | 'card' | 'full' | all three | What the visitor sees. Default 'auto'. See Payment. |
opens | 'sheet' | 'popup' | 'redirect' | the two buttons | Where the payment appears once tapped. Default 'sheet'. See Opens. |
managed | boolean | all three | Stripe becomes merchant of record for this charge. See Managed. |
asChild | true | the two buttons | Clone your own element — exactly one — instead of rendering the default button. |
children | ReactNode | the two buttons | Button label, or your element when asChild. |
className | string | all three | |
appearance | CheckoutAppearance | all three | How the provider paints its own card fields ({ stripe } / { paddle }) — they render in a frame your CSS can’t reach. |
methods | CheckoutMethods | <Checkout>, <CheckoutButton> | How the payment methods are listed. See Methods. |
classNames | CheckoutClassNames | <Checkout>, <CheckoutButton> | Classes for the SDK’s own buttons. They ship styled; these override. |
Plus every UseCheckoutOptions field (the same options useCheckout takes):
| Option | Type | Behavior |
|---|---|---|
intent | 'purchase' | 'upsell' | Default 'purchase'. Not available on <UpsellButton>, which is always an upsell. |
kind | 'subscription' | 'one-time' | 'upgrade' | For upsell intent — which kind of off-session charge. Unset, it’s taken from the offering: recurring offerings charge as 'subscription', one-off ones as 'one-time'. Set it only to override that. |
advanceOnSuccess | boolean | Advance to the next page after a successful charge. Default true. |
onSuccess | (result: CheckoutResult) => void | Fires on a successful charge. |
onError | (error: CheckoutError) => void | Fires on failure, before onFailed routing. |
onFailed | OnFailedMap | Declarative failure recovery by category — see Failure recovery. Runs after onError. |
<Checkout>
The payment renders where you put the tag. There’s no trigger, so it takes no children and no asChild — the types reject both — and no opens, because there’s nowhere else to open.
<Checkout offering="plan" /> // the best complete payment UI here
<Checkout offering="plan" payment="card" /> // a card form
<Checkout offering="plan" payment="full" /> // the provider's whole checkout, in the page[IMAGE OF a paywall with an inline card form rendered below the price]
<CheckoutButton>
The same purchase, opened from a button. Default label: “Continue”.
<CheckoutButton offering="plan">Get my plan</CheckoutButton> // the funnel's sheet
<CheckoutButton offering="plan" opens="redirect">Continue</CheckoutButton> // the provider's page
<CheckoutButton offering="plan" asChild><MyButton /></CheckoutButton> // your own triggerasChild clones your element and gives it the click handler, so it needs exactly one element child — <CheckoutButton asChild>Buy now</CheckoutButton> doesn’t compile.
<UpsellButton>
One tap charges the payment method the visitor already saved — off-session, no card re-entry, nothing opens. The post-purchase upsell primitive, for pages declared type: 'upsell'. Default label: “Add to my plan”.
<UpsellButton offering="addon">Add it</UpsellButton>
<UpsellButton offering="addon" payment="wallets">Add it</UpsellButton> // recover with a wallet
<UpsellButton offering="addon" recover={false}>Add it</UpsellButton> // don't recover at all| Prop | Type | Behavior |
|---|---|---|
recover | boolean | On a failed charge, ask for a payment method in the funnel’s sheet and charge once more. Default true. |
Recovery keeps the visitor on the page they’re already on, and the retry’s own failure routes through onFailed (e.g. prepaid_card → { go: 'real-card' }) rather than dead-ending. It only opens for a failure another attempt could fix — a visitor who already owns the upsell advances instead of being asked for a card.
payment and opens describe that recovery, since the one-tap charge shows no UI at all.
Payment
payment picks the payment UI, means the same thing on all three components, and defaults to auto.
payment | What the visitor sees |
|---|---|
auto | Default. The best way to pay that every visitor can finish in. On phones that’s the real wallet button — Apple Pay / Google Pay / Link — with the card form one tap away. On desktop it’s the method list. |
wallets | The wallet button, everywhere. Same UI auto gives a phone, pinned for every visitor. |
card | The method list — card, and whichever wallets the visitor can use, as selectable rows. See Methods. |
full | The provider’s own complete checkout: its summary, its pay button, its frame. |
The wallet button and the wallet row are different things. wallets (and auto on a phone) gives you Stripe’s own black Apple Pay button — one tap, authorize, done. card puts “Apple Pay” in the method list as a row the visitor selects before pressing your pay button. Same method, more steps.
Under wallets — and auto wherever it leads with the button — a “Pay another way” control sits below the wallet button and reveals the card form in place, without hiding the wallet. A visitor with no wallet enrolled skips straight to the card form, so neither setting can strand anyone.
Both of the SDK’s own buttons — the pay button and “Pay another way” — ship styled, and you override them with classNames:
<Checkout
offering="plan"
classNames={{ submit: 'w-full rounded-xl bg-emerald-600 py-4 font-bold text-white' }}
/>| Field | Type | Behavior |
|---|---|---|
submit | string | Class for the pay button ([data-checkout-submit]). |
payAnotherWay | string | Class for the “Pay another way” control ([data-checkout-another-way]). |
The defaults are written at zero specificity, so one Tailwind class — or a plain rule on either attribute — beats them with no !important and no dependence on stylesheet order.
Methods
methods shapes the list of payment methods in the UI the SDK renders itself — payment="card", and payment="auto" wherever that resolves to the list. It’s on <Checkout> and <CheckoutButton>, including the payment they open in the sheet.
<Checkout offering="plan" /> // accordion, collapsed
<Checkout offering="plan" methods={{ collapsed: false }} /> // first method open
<Checkout offering="plan" methods={{ layout: 'tabs' }} /> // methods across the top
<Checkout offering="plan" methods={{ order: ['apple_pay', 'card'] }} /> // lead with Apple Pay| Field | Type | Behavior |
|---|---|---|
layout | 'accordion' | 'tabs' | Default 'accordion' — the methods stack as rows. 'tabs' runs them across the top. |
collapsed | boolean | Default false — the first method opens straight away. true closes them all. |
order | string[] | Force the leading methods, e.g. ['apple_pay', 'google_pay', 'card']. Unset, the provider orders them. |
payAnotherWay | boolean | Default true. false hides the “Pay another way” control, making a wallet-led checkout wallet-only. |
The first method is open by default because a collapsed list only earns its extra tap when there’s a real choice to make. Where card is the only method — common once wallets lead as their own button — collapsing produces a row reading “Card” that the visitor must press to reach fields they already knew were coming. Set collapsed: true when you have several methods and want the visitor to choose before anything expands.
Under a wallet button, the revealed list leads with card unless you set order yourself: the wallets are already offered above, and letting one open as the first row would show its mandate text instead of the card fields the visitor just asked for.
methods is ignored, not an error, wherever the provider owns the whole UI — payment="full", anything under managed, and Paddle, which has no equivalent setting. Setting it there does nothing.
Wallets only appear in the list if the visitor’s browser has one enrolled and the funnel’s domain is registered with Stripe as a payment method domain. Without that registration the accordion shows card alone, whatever order says — and every custom domain needs registering separately.
Opens
opens is on the two button components and defaults to sheet.
opens | Where the payment appears |
|---|---|
sheet | Default. The funnel’s own sheet. The visitor stays on the page. |
popup | The provider’s own overlay. |
redirect | A full-page hand-off to the provider’s checkout, and back again when it’s paid. |
Reach for opens="redirect" inside the in-app browsers Meta and TikTok open, where overlays can fail silently. It’s the escape hatch for paid-social traffic, and the return trip is handled by <CheckoutResume>.
popup and redirect hand the provider the whole purchase, so payment narrows to auto and full beside them — the other two won’t compile.
Managed
managed puts Stripe on the hook as merchant of record for that one charge: Stripe owns the tax, the fraud checks and the disputes. It’s decided per checkout, so one funnel can sell one offering managed and another not.
<Checkout offering="plan" managed />The provider collects the card in its own frame under managed, so payment narrows to auto and full here too. Your Stripe account has to be onboarded to managed payments; the charge is refused if it isn’t.
Failure recovery
onFailed maps a failure category to what happens next, instead of hand-wiring onError. A category resolves to an exact match, then the synthetic 'default', then nothing (the error state and checkout.failed event stand).
type OnFailedMap = Partial<
Record<CheckoutErrorCategory | 'default', FailureRoute>
>
type FailureRoute =
| { go: string } // navigate the flow to a page key
| { modal: string | ComponentType } // show a registry modal (receives FailureModalProps)
| { fallback: 'sheet' } // ask for a payment method again and retry, once
| ((error: CheckoutError) => void) // full controlThe failure categories you map (CheckoutErrorCategory):
| Category | When |
|---|---|
card_declined | Generic decline. |
insufficient_funds | Not enough funds. |
expired_card | Card expired. |
prepaid_card | Prepaid card — trials and subscriptions commonly decline; route to a real-card recapture page. |
authentication_required | SCA / 3DS needed — an off-session charge can’t challenge; recover on-session. |
requires_payment_method | No usable saved method. |
processing_error | Provider-side processing error. |
canceled | The buyer dismissed the payment. Only routes when mapped explicitly. |
already_purchased | The buyer already owns this. Unmapped, the flow advances as it would after a purchase — no second charge and no second conversion. Map it to decide for yourself. |
unknown | Uncategorized. |
{ fallback } and <UpsellButton recover> ask the buyer for a payment method again, so they only
run when another attempt could actually work. A refusal that a new card can’t change — they already
own it, the offer isn’t available, the request was rejected — routes through onFailed instead of
asking. Mapping the category to a { fallback } yourself always asks.
A complete example — different recovery per reason:
import { Checkout, defineModal, Sheet, useModal } from '@appfunnel-dev/sdk'
import type { FailureModalProps } from '@appfunnel-dev/sdk'
const DownsellModal = defineModal<FailureModalProps>(({ error, go, retry }) => {
const modal = useModal()
return (
<Sheet>
<p>
{error.category === 'insufficient_funds'
? 'That card was declined.'
: 'Payment didn’t go through.'}
</p>
<button
onClick={() => {
modal.hide()
retry()
}}
>
Try another card
</button>
<button onClick={() => go('downsell')}>See the lite plan</button>
</Sheet>
)
})
function Paywall() {
return (
<Checkout
offering="plan"
onFailed={{
prepaid_card: { go: 'real-card' }, // a real-card recapture page
insufficient_funds: { modal: DownsellModal },
authentication_required: { fallback: 'sheet' }, // ask again in the sheet, retry once
default: { modal: DownsellModal },
}}
/>
)
}Two built-in judgments to know:
canceledonly routes when mapped explicitly — a deliberately dismissed payment is not a failure to recover from, sodefaultnever catches it.- A
{ fallback }recovery runs at most once per attempt chain — its own failure routes through the non-fallback routes, so declines can’t loop.
checkout.failed fires with the category and decline code on every failure regardless of routing, so your recovery pages are measurable and — being ordinary flow pages — A/B-testable like any other page.
FailureModalProps — what a { modal } route hands the modal (registry modals mount outside the nav context, so they receive the actions as closures):
interface FailureModalProps {
error: CheckoutError
product?: string // the offering slot whose charge failed
go: (pageKey: string) => void // navigate the flow (modals can't call useNavigation)
retry: (opens?: 'sheet' | 'popup' | 'redirect') => void // re-attempt the same offering
}Call retry() with no argument and it does the right thing: an upsell re-runs off-session, and a purchase asks for a payment method again in the funnel’s sheet (a retry has no page slot to render into). Pass 'popup' or 'redirect' to hand the retry to the provider instead. Hide the modal first (useModal().hide()) so the two don’t stack.
<CheckoutResume>
Handles the return from a provider-hosted checkout (opens="redirect" and opens="popup"). When a buyer comes back from paying, it verifies the purchase and resolves it into the normal success or failure path — purchase.complete fires and the funnel advances.
<CheckoutResume> is mounted for you — you never hand-place it. It’s documented here only so you know what handles the return from a hosted checkout.
Flow components
Thin, restyleable wrappers over the navigation and field hooks. Each passes className and DOM props straight through.
<Choice>
A single-select bound to a writable field.
interface ChoiceProps {
bind: string // a writable namespaced path, e.g. "responses.goal"
options: (string | ChoiceOption)[] // ChoiceOption = { value: string; label?: ReactNode }
className?: string // applied to each option button
selectedClassName?: string // added to the selected button
advanceOnSelect?: boolean // call next() after a choice (single-tap question pages)
}import { Choice } from '@appfunnel-dev/sdk'
;<Choice
bind="responses.goal"
advanceOnSelect
className="option"
selectedClassName="option--on"
options={[
{ value: 'lose', label: 'Lose weight' },
{ value: 'gain', label: 'Gain muscle' },
'maintain', // bare string ≡ { value: 'maintain', label: 'maintain' }
]}
/>Renders one <button> per option with aria-pressed and data-selected. A bare string is shorthand for { value, label } with the value as its own label. It writes through useField(bind), so bind must be a writable path.
<Next>
interface NextProps extends ButtonHTMLAttributes<HTMLButtonElement> {}A button that calls useNavigation().next(). Default label: “Continue”. Forwards all button DOM props; if your own onClick calls preventDefault(), the navigation is skipped.
<Next className="btn-primary">Continue</Next><Back>
interface BackProps extends ButtonHTMLAttributes<HTMLButtonElement> {}A button that calls back(). Renders null when there’s nowhere to go back to (!canGoBack). Default label: “Back”. Forwards all button DOM props.
Guide: Pages & Navigation.
Modals — <Sheet> / <Modal> and the registry
The modal system is an imperative, promise-based registry: any code can showModal(MyModal) from anywhere — no prop-drilling — and get back a promise that resolves when the modal calls resolve(). The runtime is mounted by the funnel provider, so there’s no provider for you to add.
defineModal
function defineModal<P extends object>(
Comp: ComponentType<P>
): ComponentType<
P & { id: string; defaultVisible?: boolean; keepMounted?: boolean }
>Wraps a component into an id-keyed modal. Inside it, useModal() (no args) gives the handle, and you render a controlled <Sheet> or <Modal>.
import { defineModal, Sheet, showModal } from '@appfunnel-dev/sdk'
const Declined = defineModal(function Declined() {
return (
<Sheet>
<p>Payment failed</p>
</Sheet>
)
})
showModal(Declined) // returns a PromiseuseModal
Overloaded:
useModal(): ModalHandler // inside a defineModal component
useModal(id: string, args?): ModalHandler
useModal(Component, args?): ModalHandlerReturns a ModalHandler:
interface ModalHandler {
id: string
args: unknown
visible: boolean
keepMounted: boolean
show: (args?) => Promise<unknown>
hide: () => Promise<unknown>
remove: () => void
resolve: (value?) => void // resolves the showModal() promise
reject: (value?) => void
resolveHide: (value?) => void
}Imperative API
Callable from anywhere in the funnel:
| Function | Signature | Behavior |
|---|---|---|
showModal | (modal: string | Component, args?) => Promise<T> | Show the modal; resolves when it resolve()s. |
hideModal | (modal) => Promise<T> | Hide; resolves after the transition. |
removeModal | (modal) => void | Remove from the tree, freeing its state. |
showModal only works inside a funnel. Called from outside one it throws
“No modal dispatch.” — which never happens in a normal funnel page.
<Sheet> and <Modal> chrome
Controlled overlay chrome — headless and styleable. <Sheet> is an edge drawer; <Modal> is a centered dialog.
type SheetProps = OverlayCommon & { side?: 'bottom' | 'top' | 'left' | 'right' } // default 'bottom'
type ModalProps = OverlayCommon
interface OverlayCommon {
className?: string
backdropClassName?: string
dismissOnBackdrop?: boolean // default true
ariaLabel?: string
children: ReactNode
}Behavior: portals to document.body, locks body scroll while open, and dismisses on Escape or a backdrop click. It ships minimal inline defaults (white panel, 16px radius, 50%-black backdrop) and no animation engine or focus trap — restyle it entirely through className / backdropClassName.
<Sheet> and <Modal> are only valid inside a defineModal component — they auto-bind to the surrounding modal via context. Used standalone (e.g. <Sheet open> dropped into a page), the internal useModal() throws. For any locally-controlled overlay, wrap it in defineModal and open it with showModal.
[IMAGE OF a bottom <Sheet> open over a paywall, showing a payment failure message and two action buttons]
Toast
import { toast, FunnelToaster } from '@appfunnel-dev/sdk'toast is sonner ’s imperative toast, re-exported directly — call it anywhere in the funnel:
toast('Saved')
toast.success('Payment complete')
toast.error('Something went wrong')
toast.dismiss()The funnel provider auto-mounts a <FunnelToaster> (top-center) unless it’s disabled. To place toasts somewhere custom, render <FunnelToaster> yourself — it accepts sonner’s ToasterProps.
<Script>
For third-party widgets that need client code — an Intercom launcher, a Trustpilot widget, a calendar embed. You declare it once and it loads at most once per id/src, even as the visitor moves between pages. Declaring a script this way is also what permits it to load.
interface ScriptProps {
src: string
strategy?: 'afterInteractive' | 'lazyOnload' // default 'afterInteractive'
id?: string // dedup key (defaults to src)
async?: boolean // default true
defer?: boolean
nonce?: string // set for you; you don't normally pass this
attributes?: Record<string, string> // extra attrs, e.g. data-*
onLoad?: () => void
onError?: () => void
}import { Script } from '@appfunnel-dev/sdk'
;<Script
src="https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js"
strategy="lazyOnload"
/><Script> renders nothing and persists across navigation (it’s never removed on unmount). afterInteractive injects on mount; lazyOnload waits for idle / load.
Use <Script> for non-analytics third-party widgets only. For analytics pixels, subscribe to the window.appfunnel bus instead — the platform already emits the full event taxonomy there, so pixels integrate by listening rather than being hand-injected.
Guide: Styling & Assets.