All recipes
Recipe
Confirm — hero image
Same Confirm slot, reversed CTA. A hero banner sits above the message, the headline uses the display font, and the save (Keep my subscription) is the prominent pill button. The cancel is a quiet underlined link. Use it when you want the visual hierarchy to favor retention without saying so in the copy. The recipe accepts HTML in description so you can inline-bold the period-end date.
custom recipe
Ready to cancel?
You'll keep access until June 14. After that, your projects switch to read-only — they don't go away.
The full source
Copy this file into your project. It's the same file the showcase preview above renders from.
components/ConfirmWithHero.tsxtsx
/**
* Canva-style confirm with a hero banner stacked above the message and a
* reversed CTA: the prominent button is the save ("Keep my subscription")
* and "Cancel anyway" is a quiet underlined link. Use it when you want the
* cancellation moment to lean toward the save without being aggressive
* about it — the visual weight does the work that copy used to.
*
* Wire it as a step-level override:
*
* <CancelFlow
* ...
* components={{ Confirm: ConfirmWithHero }}
* />
*
* The hero negative-margins out of `.ck-content`'s 32/24/24 padding so it
* reaches the modal edge; if you've customized the inset, adjust the
* `margin` value below.
*
* Swap `<HeroPanel />` for an `<img src="..." />` to use a real brand
* asset. Inline-bold the period-end date in `description` (HTML is
* supported via `dangerouslySetInnerHTML` — see the note on the <p> tag).
*/
import type { ConfirmStepProps } from '@churnkey/react/core'
export function ConfirmWithHero({
title,
description,
confirmLabel,
goBackLabel,
onConfirm,
onGoBack,
isProcessing,
}: ConfirmStepProps) {
return (
<div
// Escape the SDK's content inset so the hero reaches the modal edge.
// Inner content re-applies horizontal padding so it stays aligned
// with the rest of the flow.
style={{ margin: '-32px -24px -24px' }}
>
<HeroPanel />
<div style={{ padding: '28px 24px 24px', textAlign: 'center' }}>
<h2
style={{
fontFamily: 'var(--ck-font-display)',
fontSize: 26,
fontWeight: 600,
letterSpacing: '-0.012em',
lineHeight: 1.15,
margin: '0 0 12px',
color: 'var(--ck-color-text)',
}}
>
{title}
</h2>
{description && (
// The SDK's default confirm renders description through its
// RichText component (HTML allowed). If your description comes
// from trusted dashboard copy, mirror that here — otherwise
// plain text is the safer default.
<p
style={{
fontSize: 14.5,
color: 'var(--ck-color-text-secondary)',
lineHeight: 1.55,
margin: '0 auto 24px',
maxWidth: 340,
}}
// biome-ignore lint/security/noDangerouslySetInnerHtml: description is dashboard/config copy, not user input
dangerouslySetInnerHTML={{ __html: description }}
/>
)}
{/* Reversed hierarchy: the save is the loud action, cancel is the
quiet escape. `onGoBack` keeps the subscription; `onConfirm`
commits the cancel. */}
<button
type="button"
onClick={onGoBack}
style={{
width: '100%',
padding: '15px 20px',
background: 'var(--ck-color-primary)',
color: '#fff',
border: 'none',
borderRadius: 999,
fontSize: 15,
fontWeight: 600,
fontFamily: 'inherit',
cursor: 'pointer',
marginBottom: 16,
}}
>
{goBackLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={isProcessing}
style={{
background: 'transparent',
border: 'none',
color: 'var(--ck-color-text-secondary)',
fontSize: 13.5,
fontFamily: 'inherit',
textDecoration: 'underline',
textUnderlineOffset: 3,
cursor: isProcessing ? 'not-allowed' : 'pointer',
opacity: isProcessing ? 0.6 : 1,
}}
>
{isProcessing ? 'Processing...' : confirmLabel}
</button>
</div>
</div>
)
}
// Warm sunset gradient with floating shapes. Decorative — swap for a real
// brand <img> when you have one. Aspect picked so the panel reads as a
// banner, not a square hero.
function HeroPanel() {
return (
<div
aria-hidden
style={{
position: 'relative',
overflow: 'hidden',
height: 200,
background:
'radial-gradient(120% 90% at 70% 110%, #ff5b6b 0%, #ff8a3d 35%, #ffc24a 65%, #ffe39a 100%)',
}}
>
{/* Off-white rounded square, top-left */}
<div
style={{
position: 'absolute',
top: 28,
left: 36,
width: 56,
height: 56,
borderRadius: 14,
background: '#fbf2e4',
transform: 'rotate(-12deg)',
}}
/>
{/* Dark dot, center-ish */}
<div
style={{
position: 'absolute',
top: 96,
left: '46%',
width: 22,
height: 22,
borderRadius: '50%',
background: '#1b1b1b',
}}
/>
{/* Beige pebble, right of center */}
<div
style={{
position: 'absolute',
top: 86,
right: '28%',
width: 36,
height: 32,
borderRadius: '46% 54% 60% 40% / 50% 60% 40% 50%',
background: '#f0e0c9',
}}
/>
{/* Cobalt droplet, top-right */}
<div
style={{
position: 'absolute',
top: 22,
right: 28,
width: 38,
height: 42,
borderRadius: '52% 48% 58% 42% / 56% 44% 60% 40%',
background: '#2e44d8',
}}
/>
</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 '@churnkey/react/styles.css'
import { ConfirmWithHero } from './components/ConfirmWithHero'
<CancelFlow
steps={[
{ type: 'survey', reasons: [/* ... */] },
{ type: 'feedback' },
{
type: 'confirm',
title: 'Ready to cancel?',
// The recipe renders description as HTML, so you can inline-bold
// the period-end date or other key terms.
description:
"You'll keep access until <strong>June 14</strong>. After that, your projects switch to read-only \u2014 they don't go away.",
confirmLabel: 'No thanks, cancel anyway',
goBackLabel: 'Keep my subscription',
},
]}
components={{ Confirm: ConfirmWithHero }}
handleCancel={async () => myBilling.cancel()}
/>