All recipes
Recipe
Plan change — stacked rows
The default plan-change picker shows plans as compact rows with name, tagline, and price. It works well when plans differ mainly on price. When plans differ in features — different integrations, different seat counts, different support tiers — the compact rows don't have room to show the differences. This recipe overrides PlanChangeOffer with a stacked-row layout that expands the selected plan inline to show its full feature list. The selected plan id lands on AcceptedOffer.result.planId.
custom recipe
A different plan might fit
Pick the one that matches your usage.
The full source
Copy this file into your project. It's the same file the showcase preview above renders from.
components/PlanChangeStackedRows.tsxtsx
/**
* Plan-change offer rendered as stacked rows instead of side-by-side cards.
* Better when you have 3+ plans, when feature lists are long, or when the
* comparison-by-row reads better than comparison-by-column.
*
* Wire it as a per-type override:
*
* <CancelFlow
* ...
* components={{ PlanChangeOffer: PlanChangeStackedRows }}
* />
*
* The selected plan id lands on `AcceptedOffer.result.planId`.
*/
import type { OfferDecision, OfferStepProps, PlanOption } from '@churnkey/react/core'
import { useState } from 'react'
// Plan amount.value is the smallest currency unit (cents for USD). For
// zero-decimal currencies (JPY, KRW...) drop the /100, or swap this for
// your project's currency formatter.
function formatPrice(minorAmount: number, currency: string) {
return new Intl.NumberFormat(undefined, { style: 'currency', currency, currencyDisplay: 'narrowSymbol' })
.format(minorAmount / 100)
.replace(/\.00$/, '')
}
export function PlanChangeStackedRows({
title,
description,
offer,
onAccept,
onDecline,
isProcessing,
}: OfferStepProps) {
const o = offer as OfferDecision & { plans?: PlanOption[] }
const plans = o.plans ?? []
const [selectedId, setSelectedId] = useState<string | null>(plans[0]?.id ?? null)
const selected = plans.find((p) => p.id === selectedId) ?? null
const headline = title ?? offer.copy.headline
const body = description ?? offer.copy.body
return (
<div className="ck-step ck-step-offer">
{headline && <h2 className="ck-step-title">{headline}</h2>}
{body && <p className="ck-step-description">{body}</p>}
<div className="ck-offer-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
{plans.map((plan) => {
const interval = plan.duration?.interval ?? 'month'
const currency = plan.amount.currency ?? 'USD'
const isSelected = plan.id === selectedId
return (
<button
key={plan.id}
type="button"
onClick={() => setSelectedId(plan.id)}
aria-pressed={isSelected}
style={{
appearance: 'none',
textAlign: 'left',
padding: 16,
background: 'var(--ck-color-surface)',
border: `1.5px solid ${isSelected ? 'var(--ck-color-primary)' : 'var(--ck-color-border)'}`,
borderRadius: 'var(--ck-radius-md)',
cursor: 'pointer',
transition: 'all var(--ck-motion-fast)',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span
aria-hidden
style={{
width: 18,
height: 18,
borderRadius: 999,
border: `1.5px solid ${isSelected ? 'var(--ck-color-primary)' : 'var(--ck-color-border-strong)'}`,
background: 'var(--ck-color-surface)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{isSelected && (
<span
style={{
width: 8,
height: 8,
borderRadius: 999,
background: 'var(--ck-color-primary)',
}}
/>
)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{plan.name ?? plan.id}</span>
<span style={{ fontSize: 14, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{formatPrice(plan.amount.value, currency)}
<span style={{ fontSize: 12, color: 'var(--ck-color-text-muted)', fontWeight: 500, marginLeft: 4 }}>
/{interval}
</span>
</span>
</div>
{plan.tagline && (
<div style={{ fontSize: 12, color: 'var(--ck-color-text-secondary)', marginTop: 2 }}>
{plan.tagline}
</div>
)}
{isSelected && plan.features && plan.features.length > 0 && (
<ul
style={{
listStyle: 'none',
padding: 0,
margin: '12px 0 0',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
{plan.features.map((f, i) => (
<li
key={`${plan.id}-${i}`}
style={{ fontSize: 12, color: 'var(--ck-color-text-secondary)' }}
>
✓ {f}
</li>
))}
</ul>
)}
</div>
</div>
</button>
)
})}
</div>
<button
type="button"
className="ck-button ck-button-primary"
onClick={() => selectedId && onAccept({ planId: selectedId })}
disabled={isProcessing || !selectedId}
>
{isProcessing
? 'Processing...'
: selected?.name
? `Switch to ${selected.name}`
: offer.copy.cta}
</button>
<button type="button" className="ck-button-link" onClick={onDecline}>
{offer.copy.declineCta}
</button>
</div>
</div>
)
}
Wire it into a flow
Drop the file into your codebase and reference it from the appropriate prop on <CancelFlow>.
CancelButton.tsxtsx
import { CancelFlow } from '@churnkey/react'
import { PlanChangeStackedRows } from './components/PlanChangeStackedRows'
<CancelFlow
steps={[
{
type: 'survey',
reasons: [{
id: 'expensive',
label: 'Too expensive',
offer: {
type: 'plan_change',
plans: [
{ id: 'price_starter', name: 'Starter',
amount: { value: 900, currency: 'USD' },
tagline: 'Lighter usage',
features: ['5 projects', 'Single workspace', 'Email support'] },
{ id: 'price_hobby', name: 'Hobby',
amount: { value: 0, currency: 'USD' },
tagline: 'Free forever',
features: ['1 project', 'Community support'] },
],
},
}],
},
{ type: 'confirm' },
]}
components={{ PlanChangeOffer: PlanChangeStackedRows }}
handlePlanChange={async (offer) => myBilling.changePlan(offer.result.planId)}
handleCancel={async () => myBilling.cancel()}
/>