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()}
/>