Skip to Content
ReferenceComponents

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 saved

All 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

PropTypeOnBehavior
offeringstringall threeThe offering slot id. Required.
payment'auto' | 'wallets' | 'card' | 'full'all threeWhat the visitor sees. Default 'auto'. See Payment.
opens'sheet' | 'popup' | 'redirect'the two buttonsWhere the payment appears once tapped. Default 'sheet'. See Opens.
managedbooleanall threeStripe becomes merchant of record for this charge. See Managed.
asChildtruethe two buttonsClone your own element — exactly one — instead of rendering the default button.
childrenReactNodethe two buttonsButton label, or your element when asChild.
classNamestringall three
appearanceCheckoutAppearanceall threeHow the provider paints its own card fields ({ stripe } / { paddle }) — they render in a frame your CSS can’t reach.
methodsCheckoutMethods<Checkout>, <CheckoutButton>How the payment methods are listed. See Methods.
classNamesCheckoutClassNames<Checkout>, <CheckoutButton>Classes for the SDK’s own buttons. They ship styled; these override.

Plus every UseCheckoutOptions field (the same options useCheckout takes):

OptionTypeBehavior
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.
advanceOnSuccessbooleanAdvance to the next page after a successful charge. Default true.
onSuccess(result: CheckoutResult) => voidFires on a successful charge.
onError(error: CheckoutError) => voidFires on failure, before onFailed routing.
onFailedOnFailedMapDeclarative 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 trigger

asChild 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
PropTypeBehavior
recoverbooleanOn 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.

paymentWhat the visitor sees
autoDefault. 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.
walletsThe wallet button, everywhere. Same UI auto gives a phone, pinned for every visitor.
cardThe method list — card, and whichever wallets the visitor can use, as selectable rows. See Methods.
fullThe 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' }} />
FieldTypeBehavior
submitstringClass for the pay button ([data-checkout-submit]).
payAnotherWaystringClass 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
FieldTypeBehavior
layout'accordion' | 'tabs'Default 'accordion' — the methods stack as rows. 'tabs' runs them across the top.
collapsedbooleanDefault false — the first method opens straight away. true closes them all.
orderstring[]Force the leading methods, e.g. ['apple_pay', 'google_pay', 'card']. Unset, the provider orders them.
payAnotherWaybooleanDefault 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.

opensWhere the payment appears
sheetDefault. The funnel’s own sheet. The visitor stays on the page.
popupThe provider’s own overlay.
redirectA 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 control

The failure categories you map (CheckoutErrorCategory):

CategoryWhen
card_declinedGeneric decline.
insufficient_fundsNot enough funds.
expired_cardCard expired.
prepaid_cardPrepaid card — trials and subscriptions commonly decline; route to a real-card recapture page.
authentication_requiredSCA / 3DS needed — an off-session charge can’t challenge; recover on-session.
requires_payment_methodNo usable saved method.
processing_errorProvider-side processing error.
canceledThe buyer dismissed the payment. Only routes when mapped explicitly.
already_purchasedThe 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.
unknownUncategorized.

{ 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:

  • canceled only routes when mapped explicitly — a deliberately dismissed payment is not a failure to recover from, so default never 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 Promise

useModal

Overloaded:

useModal(): ModalHandler // inside a defineModal component useModal(id: string, args?): ModalHandler useModal(Component, args?): ModalHandler

Returns 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:

FunctionSignatureBehavior
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) => voidRemove 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.

Last updated on