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:

LayerKeysUse when
StructuralModal, CloseButton, BackButtonYour design system has its own dialog primitive.
Step-levelSurvey, Offer, Feedback, Confirm, SuccessAn entire step type needs different markup.
Per-offer-type and sub-componentsDiscountOffer, PauseOffer, PlanChangeOffer, TrialExtensionOffer, ContactOffer, RedirectOffer, ReasonButtonOne 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:

  1. If components.Offer is set, the SDK renders it. The component is responsible for dispatching to per-type rendering itself.
  2. Otherwise, the SDK's default Offer runs and dispatches to components.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