Docs navigation
Replacing components
The components prop replaces a built-in component with one of your own. The SDK still handles navigation, accept/decline logic, and session recording.
Use it when CSS isn't enough — rendering reasons as cards with screenshots, swapping the modal for your design system's dialog, embedding a video in an offer step.
<CancelFlow
steps={steps}
components={{
ReasonButton: ({ reason, isSelected, onSelect }) => (
<button
onClick={() => onSelect(reason.id)}
className={isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}
>
{reason.label}
</button>
),
}}
handleCancel={handleCancel}
/>Three layers
The components prop accepts three kinds of override, from outermost to innermost:
| Layer | Keys | Use when |
|---|---|---|
| Structural | Modal, CloseButton, BackButton | Your design system has its own dialog primitive. |
| Step-level | Survey, Offer, Feedback, Confirm, Success | An entire step type needs different markup. |
| Per-offer-type and sub-components | DiscountOffer, PauseOffer, PlanChangeOffer, TrialExtensionOffer, ContactOffer, RedirectOffer, ReasonButton | One offer type or one sub-element needs a different layout. |
The layers compose. Modal wraps every step regardless of which one is inside. On an offer step, the SDK renders your Offer if you've set one; otherwise the default Offer runs and dispatches to your per-type override (e.g. DiscountOffer) or the default for that type.
Replace a single sub-component
Replace ReasonButton to change how reasons render in the survey. Other steps and offers stay on their defaults.
<CancelFlow
steps={steps}
components={{
ReasonButton: ({ reason, isSelected, onSelect }) => (
<button
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onSelect(reason.id)}
className={`reason-card ${isSelected ? 'is-selected' : ''}`}
>
<span className="reason-card__label">{reason.label}</span>
</button>
),
}}
/>The SDK calls ReasonButton once per reason, passing the reason config, the index, the selection state, and the onSelect callback. Selection tracking stays in the SDK's selectedReason state.
Replace one offer type
Override only the offer types that need different rendering. Unset offer types use the defaults.
<CancelFlow
steps={steps}
components={{
PlanChangeOffer: MyPlanChangeCards,
// DiscountOffer, PauseOffer, etc. continue to use the defaults
}}
/>See the Plan change as stacked rows recipe for a complete PlanChangeOffer override.
Replace an entire step
Override Survey to take ownership of the whole survey step. The SDK passes the step's state (reasons, selectedReason, onSelectReason, onNext).
<CancelFlow
steps={steps}
components={{
Survey: ({ title, description, reasons, selectedReason, onSelectReason, onNext, classNames }) => (
<div>
<h2>{title}</h2>
{description && <p>{description}</p>}
<ul>
{reasons.map((r) => (
<li key={r.id}>
<button onClick={() => onSelectReason(r.id)}>{r.label}</button>
</li>
))}
</ul>
<button onClick={onNext} disabled={!selectedReason}>Continue</button>
</div>
),
}}
/>classNames is the step-level config from the steps array. Forwarding it is optional; doing so keeps per-step className overrides working when the step is replaced.
How Offer and per-type overrides compose
Both Offer and DiscountOffer are valid keys. On an offer step, the SDK resolves them in this order:
- If
components.Offeris set, the SDK renders it. The component is responsible for dispatching to per-type rendering itself. - Otherwise, the SDK's default
Offerruns and dispatches tocomponents.DiscountOffer,components.PauseOffer, etc. — or the default for that type if no override is set.
Use Offer to wrap every offer regardless of type — a "Last chance" badge, a customer quote, a consistent header. Use per-type overrides when one specific offer type needs a different layout and the others should stay on defaults.
Component prop shapes
Every step component and per-offer-type component receives customer and subscriptions alongside its step-specific props. The defaults derive what they need from these: DefaultConfirm formats a period-end notice from subscriptions[0].status, DefaultPlanChangeOffer reads subscriptions[0].items[0].price.id to mark the current plan.
interface ModalProps {
open: boolean
onClose: () => void
children: React.ReactNode
className?: string
overlayClassName?: string
}
interface ReasonButtonProps {
reason: ReasonConfig
index: number
isSelected: boolean
onSelect: (id: string) => void
}
interface OfferStepProps {
title?: string
description?: string
customer: DirectCustomer | null
subscriptions: DirectSubscription[]
offer: OfferDecision // narrowed for per-type overrides
onAccept: (result?: object) => Promise<void>
onDecline: () => void
isProcessing: boolean
classNames?: OfferClassNames
components?: Partial<ComponentOverrides> // forwarded so the default Offer can dispatch to per-type slots
}
interface SurveyStepProps {
title: string
description?: string
customer: DirectCustomer | null
subscriptions: DirectSubscription[]
reasons: ReasonConfig[]
selectedReason: string | null
onSelectReason: (id: string) => void
followupResponse: string
onFollowupResponseChange: (text: string) => void
onNext: () => void
classNames?: SurveyClassNames
components?: Partial<ComponentOverrides> // forwarded so your Survey can re-use ReasonButton
}
interface FeedbackStepProps {
title: string
description?: string
customer: DirectCustomer | null
subscriptions: DirectSubscription[]
placeholder?: string
required: boolean
minLength: number
value: string
onChange: (text: string) => void
onSubmit: () => void
classNames?: FeedbackClassNames
}
interface ConfirmStepProps {
title: string
description?: string
customer: DirectCustomer | null
subscriptions: DirectSubscription[]
losses?: string[] // bulleted list of what the customer is losing
lossesLabel?: string // heading above the list
confirmLabel: string
goBackLabel: string
onConfirm: () => Promise<void>
onGoBack: () => void
isProcessing: boolean
classNames?: ConfirmClassNames
}
interface SuccessStepProps {
outcome: 'saved' | 'cancelled'
offer?: OfferDecision // populated when outcome === 'saved'
title: string
description?: string
customer: DirectCustomer | null
subscriptions: DirectSubscription[]
onClose: () => void
classNames?: SuccessClassNames
}There's no error prop on any of the shapes. When onAccept or onConfirm throws, the SDK re-renders the step with isProcessing: false and the customer can press the action button again. See Custom step + offer types for the equivalent pattern with custom components.
See the API reference for the full list.
Next steps
- Custom step + offer types — add entirely new step types alongside the built-ins.
- Theming — color scheme and design tokens.
- Plan change as stacked rows recipe — a complete
PlanChangeOfferoverride.