Skip to content
Product block

Subscription Cancel Flow

Self-serve cancellation with no dark patterns: the exact date access ends, what breaks, one honest alternative, and a button that works.

273 lineslucide-reactAdded 25 Aug 2026
  • cancel
  • churn
  • subscription
  • retention
  • billing

What's included

  • components/subscription-cancel-flow.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add subscription-cancel-flow

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Cancel your Team plan

You can do this yourself, right here, and it takes one click once you have read what happens.

What happens, and when

  • You keep Team until 14 October 2026 — the period you have already paid for. Nothing stops today.
  • You will not be charged again, and there is no cancellation fee.
  • After that, your data is kept for 30 days and restoring is one click. It is deleted after that.

What breaks on 14 October 2026

  • API keys stop working, and any integration using them fails
  • Team members lose access to shared projects
  • Scheduled exports stop running

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/subscription-cancel-flow.tsx
'use client'

/**
 * <SubscriptionCancelFlow> — the screen a company would rather not build.
 *
 * Billing & Usage had the plan, the invoices and the card on file, which is
 * every screen except the one someone actually goes looking for. Cancelling
 * is the most emotionally loaded flow in a subscription product and it is
 * almost always the worst-built one, because nobody wants to design it and
 * the person who eventually does is asked to reduce churn rather than to
 * make it work.
 *
 * WHAT THIS BLOCK REFUSES TO DO
 *
 * No dark patterns, and the omissions are deliberate rather than lazy:
 *
 *   No hidden button. Cancel is a real control, findable, not a link in
 *   the footer of a settings tab.
 *   No fake urgency, no countdown, no "are you sure?" repeated three
 *   times. One confirmation, because destructive actions deserve one.
 *   No support-chat interception. A queue between someone and the exit is
 *   a hostage negotiation with a spinner.
 *   No requirement to give a reason. The reason select can be skipped, and
 *   the button works either way.
 *
 * These are not only ethics. The EU's own consumer rules are moving toward
 * cancellation being as easy as signup, several US states already require
 * it, and a flow built on friction is a rewrite waiting for a regulator.
 *
 * WHAT IT DOES INSTEAD, AND WHY IT STILL SAVES SOME
 *
 * It tells the truth about what happens next, precisely: the date access
 * ends, that the plan runs to the end of the period already paid for, how
 * long the data is kept, and what would be lost. A surprising share of
 * cancellations are "I don't need it this quarter" rather than "I am
 * leaving", and the honest alternatives — pause, or move down a tier —
 * are offered once, plainly, next to the real button rather than in front
 * of it.
 *
 * The reason select earns its place by being useful to the person leaving
 * too: picking "too expensive" surfaces the cheaper tier, picking "missing
 * a feature" surfaces the changelog. An answer that changes what the page
 * shows is worth giving.
 *
 * ACCESSIBILITY: the consequence list is read before the button in DOM
 * order, and the confirmation input is a real labelled field rather than a
 * placeholder — someone using a screen reader hears what they are about to
 * lose and what they must type, in that order.
 */

import * as React from 'react'
import { ArrowDownRight, CalendarClock, PauseCircle, ShieldAlert } from 'lucide-react'

export interface CancelReason {
  value: string
  label: string
  /** Shown when picked. The one thing that might genuinely help. */
  followUp?: { text: string; actionLabel: string }
}

export interface SubscriptionCancelFlowProps {
  planName?: string
  /** Human date the paid period runs to. */
  paidUntil?: string
  /** How long data is retained after access ends. */
  retentionDays?: number
  losses?: string[]
  reasons?: CancelReason[]
  /** Typed to confirm. Kept short — this is a speed bump, not a test. */
  confirmWord?: string
  onCancel?: (reason: string) => void
  onPause?: () => void
  onDowngrade?: () => void
  className?: string
}

const DEFAULT_REASONS: CancelReason[] = [
  {
    value: 'expensive',
    label: 'Too expensive',
    followUp: {
      text: 'Starter is $9 a month and keeps your projects, history and API access. Most of what you use is in it.',
      actionLabel: 'Move to Starter instead',
    },
  },
  {
    value: 'missing',
    label: 'Missing something I need',
    followUp: {
      text: 'Worth checking the changelog before you go — a lot shipped this quarter, and if it is still missing we would like to know what it is.',
      actionLabel: 'See what shipped',
    },
  },
  {
    value: 'unused',
    label: 'Not using it enough',
    followUp: {
      text: 'You can pause for up to three months. Billing stops, nothing is deleted, and everything is where you left it.',
      actionLabel: 'Pause instead',
    },
  },
  { value: 'switching', label: 'Moving to something else' },
  { value: 'project-over', label: 'The project ended' },
  { value: 'other', label: 'Something else' },
]

const DEFAULT_LOSSES = [
  'API keys stop working, and any integration using them fails',
  'Team members lose access to shared projects',
  'Scheduled exports stop running',
]

export function SubscriptionCancelFlow({
  planName = 'Team',
  paidUntil = '14 October 2026',
  retentionDays = 30,
  losses = DEFAULT_LOSSES,
  reasons = DEFAULT_REASONS,
  confirmWord = 'cancel',
  onCancel,
  onPause,
  onDowngrade,
  className = '',
}: SubscriptionCancelFlowProps) {
  /*
    Per-instance prefix for every id this block emits.

    The literals these replaced were a latent duplicate the moment the
    block appeared twice on one document, and `aria-labelledby` on a
    duplicated id resolves to the first match -- so the second copy was
    labelled by the first copy's heading. Client component, so `useId` is
    the right tool.
  */
  const uid = React.useId()

  const [reason, setReason] = React.useState('')
  const [typed, setTyped] = React.useState('')

  const picked = reasons.find((r) => r.value === reason)
  const confirmed = typed.trim().toLowerCase() === confirmWord.toLowerCase()

  return (
    <section
      aria-labelledby={`${uid}-cancel-heading`}
      className={`mx-auto w-full max-w-2xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}
    >
      <div className="rounded-2xl border border-border bg-card p-6 sm:p-8">
        <h2 id={`${uid}-cancel-heading`} className="text-xl font-bold tracking-tight text-foreground">
          Cancel your {planName} plan
        </h2>
        <p className="mt-2 text-sm text-muted-foreground">
          You can do this yourself, right here, and it takes one click once you
          have read what happens.
        </p>

        {/* The consequences, before anything is asked of them. */}
        <div className="mt-6 rounded-xl border border-border bg-muted/30 p-4">
          <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
            <CalendarClock aria-hidden className="h-4 w-4 text-muted-foreground" />
            What happens, and when
          </h3>
          <ul className="mt-3 space-y-2 text-sm text-muted-foreground">
            <li>
              You keep {planName} until{' '}
              <span className="font-medium text-foreground">{paidUntil}</span> — the
              period you have already paid for. Nothing stops today.
            </li>
            <li>
              You will not be charged again, and there is no cancellation fee.
            </li>
            <li>
              After that, your data is kept for{' '}
              <span className="font-medium text-foreground">{retentionDays} days</span>{' '}
              and restoring is one click. It is deleted after that.
            </li>
          </ul>
        </div>

        <div className="mt-4 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
          <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
            <ShieldAlert aria-hidden className="h-4 w-4 text-destructive" />
            What breaks on {paidUntil}
          </h3>
          <ul className="mt-3 list-inside list-disc space-y-1.5 text-sm text-muted-foreground">
            {losses.map((loss) => (
              <li key={loss}>{loss}</li>
            ))}
          </ul>
        </div>

        <div className="mt-6">
          <label htmlFor={`${uid}-cancel-reason`} className="text-sm font-medium text-foreground">
            Why are you cancelling?{' '}
            <span className="font-normal text-muted-foreground">
              — optional, and it does not change anything below
            </span>
          </label>
          <select
            id={`${uid}-cancel-reason`}
            value={reason}
            onChange={(event) => setReason(event.target.value)}
            className="mt-2 h-9 w-full rounded-lg border border-field bg-background px-3 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            <option value="">Rather not say</option>
            {reasons.map((option) => (
              <option key={option.value} value={option.value}>
                {option.label}
              </option>
            ))}
          </select>

          {/*
            Offered once, beside the exit rather than in front of it. If it
            is not relevant they scroll past one paragraph.
          */}
          {picked?.followUp ? (
            <div
              role="status"
              className="mt-3 rounded-xl border border-border bg-background p-4"
            >
              <p className="text-sm text-muted-foreground">{picked.followUp.text}</p>
              <button
                type="button"
                onClick={picked.value === 'unused' ? onPause : onDowngrade}
                className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                {picked.value === 'unused' ? (
                  <PauseCircle aria-hidden className="h-4 w-4" />
                ) : (
                  <ArrowDownRight aria-hidden className="h-4 w-4" />
                )}
                {picked.followUp.actionLabel}
              </button>
            </div>
          ) : null}
        </div>

        <div className="mt-6 border-t border-border pt-6">
          <label htmlFor={`${uid}-cancel-confirm`} className="block text-sm font-medium text-foreground">
            Type <span className="font-mono text-foreground">{confirmWord}</span> to
            confirm
          </label>
          <input
            id={`${uid}-cancel-confirm`}
            type="text"
            value={typed}
            onChange={(event) => setTyped(event.target.value)}
            autoComplete="off"
            className="mt-2 h-9 w-full rounded-lg border border-field bg-background px-3 font-mono text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:w-48"
          />

          <div className="mt-4 flex flex-wrap items-center gap-3">
            <button
              type="button"
              disabled={!confirmed}
              onClick={() => onCancel?.(reason)}
              className="inline-flex h-9 items-center rounded-lg bg-destructive px-4 text-sm font-semibold text-destructive-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
            >
              Cancel my subscription
            </button>
            <button
              type="button"
              className="inline-flex h-9 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              Keep my plan
            </button>
          </div>
        </div>
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/subscription-cancel-flow.tsx and import it where you need the section:

import { SubscriptionCancelFlow } from '@/components/subscription-cancel-flow'

Customize

4 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
planNamestring'Team'
paidUntilHuman date the paid period runs to.string'14 October 2026'
retentionDaysHow long data is retained after access ends.number30
lossesstring[]DEFAULT_LOSSES
reasonsCancelReason[]DEFAULT_REASONS
confirmWordTyped to confirm. Kept short — this is a speed bump, not a test.string'cancel'
onCancel(reason: string) => void—
onPause() => void—
onDowngrade() => void—
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

subscription-cancel-flow.html
<!--
  Subscription Cancel Flow — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section aria-labelledby="_R_0_-cancel-heading" class="mx-auto w-full max-w-2xl px-4 py-16 sm:px-6 lg:px-8 ">
  <div class="rounded-2xl border border-border bg-card p-6 sm:p-8">
    <h2 id="_R_0_-cancel-heading" class="text-xl font-bold tracking-tight text-foreground">Cancel your Team plan</h2>
    <p class="mt-2 text-sm text-muted-foreground">You can do this yourself, right here, and it takes one click once you have read what happens.</p>
    <div class="mt-6 rounded-xl border border-border bg-muted/30 p-4">
      <h3 class="flex items-center gap-2 text-sm font-semibold text-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-clock h-4 w-4 text-muted-foreground" aria-hidden="true"><path d="M16 14v2.2l1.6 1"></path><path d="M16 2v4"></path><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"></path><path d="M3 10h5"></path><path d="M8 2v4"></path><circle cx="16" cy="16" r="6"></circle></svg>What happens, and when</h3>
      <ul class="mt-3 space-y-2 text-sm text-muted-foreground">
        <li>You keep Team until <span class="font-medium text-foreground">14 October 2026</span> — the period you have already paid for. Nothing stops today.</li>
        <li>You will not be charged again, and there is no cancellation fee.</li>
        <li>After that, your data is kept for <span class="font-medium text-foreground">30 days</span> and restoring is one click. It is deleted after that.</li>
      </ul>
    </div>
    <div class="mt-4 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
      <h3 class="flex items-center gap-2 text-sm font-semibold text-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-alert h-4 w-4 text-destructive" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path><path d="M12 8v4"></path><path d="M12 16h.01"></path></svg>What breaks on 14 October 2026</h3>
      <ul class="mt-3 list-inside list-disc space-y-1.5 text-sm text-muted-foreground">
        <li>API keys stop working, and any integration using them fails</li>
        <li>Team members lose access to shared projects</li>
        <li>Scheduled exports stop running</li>
      </ul>
    </div>
    <div class="mt-6">
      <label for="_R_0_-cancel-reason" class="text-sm font-medium text-foreground">Why are you cancelling? <span class="font-normal text-muted-foreground">— optional, and it does not change anything below</span></label>
      <select id="_R_0_-cancel-reason" class="mt-2 h-9 w-full rounded-lg border border-field bg-background px-3 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        <option value="" selected="">Rather not say</option>
        <option value="expensive">Too expensive</option>
        <option value="missing">Missing something I need</option>
        <option value="unused">Not using it enough</option>
        <option value="switching">Moving to something else</option>
        <option value="project-over">The project ended</option>
        <option value="other">Something else</option>
      </select>
    </div>
    <div class="mt-6 border-t border-border pt-6">
      <label for="_R_0_-cancel-confirm" class="block text-sm font-medium text-foreground">Type <span class="font-mono text-foreground">cancel</span> to confirm</label>
      <input id="_R_0_-cancel-confirm" type="text" autoComplete="off" class="mt-2 h-9 w-full rounded-lg border border-field bg-background px-3 font-mono text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:w-48" value="" />
      <div class="mt-4 flex flex-wrap items-center gap-3">
        <button type="button" disabled="" class="inline-flex h-9 items-center rounded-lg bg-destructive px-4 text-sm font-semibold text-destructive-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50">Cancel my subscription</button>
        <button type="button" class="inline-flex h-9 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Keep my plan</button>
      </div>
    </div>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Billing & Usage

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Billing & Usage

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Billing & Usage blocks

View category
Open the full page for this block

Plan & Next Charge Summary

Current plan, next charge with an actual figure and date, payment method, and a prominent pending-cancellation state.

Billing & Usage151 lines1 dep
Open the full page for this block

Invoice History

Past invoices with tabular figures, worded statuses rather than bare coloured dots, and per-invoice download labels.

Billing & Usage174 lines1 dep
Open the full page for this block

Payment Methods On File

Cards on file with the two failures that cost the subscription: expiry called out before it fails, and the default stated on the row rather than implied by ordering.

Billing & Usage248 lines1 dep
Open the full page for this block

Overage Notice with Projection

Past the included allowance, with the end-of-month projection and every number the invoice will use — because a surprise bill is a product defect.

Billing & Usage230 lines1 dep
Open the full page for this block

Seat Count & Proration

Changing seats with the bill shown first: the part-period charge and the new recurring amount as two numbers, the arithmetic spelled out, and the asymmetry stated where it bites — removing a seat refunds nothing today.

Billing & Usage254 lines1 dep
Open the full page for this block

Invoice Detail

One invoice with the arithmetic shown: proration split into a credit and a charge with their own date ranges, tax as a line with its jurisdiction, and totals in a real tfoot.

Billing & Usage265 lines1 dep
Open the full page for this block

Credit Balance and Expiry

Consumable credits split into the buckets they actually live in, rendered in spend order, with a proportional warning that only appears when a meaningful balance is about to expire.

Billing & Usage222 lines1 dep