Retention — Cancellation / downgrade flow
A three-step cancellation and retention flow for a fictional Northwind SaaS subscription. Step one collects a cancel reason from a radio list, step two surfaces a tailored save offer that adapts to that reason — a 40 percent discount, a downgrade to Starter, a pause with selectable duration, or a specialist handoff — and step three confirms the cancellation with a feedback field and a clear warning. Accepting an offer routes to a saved screen, while confirming routes to a goodbye screen with a reference code.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
background-image:
radial-gradient(60rem 40rem at 12% -10%, rgba(91, 91, 240, 0.07), transparent 60%),
radial-gradient(46rem 34rem at 100% 0%, rgba(0, 180, 166, 0.06), transparent 55%);
background-attachment: fixed;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
button {
font: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
/* ---------- Page shell ---------- */
.page {
max-width: 600px;
margin: 0 auto;
padding: 28px 20px 64px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
}
.brand-mark {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 11px;
color: var(--white);
background: linear-gradient(135deg, var(--brand), var(--brand-700));
box-shadow: var(--sh-1);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.brand-text strong {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-text span {
font-size: 12px;
color: var(--muted);
}
.account-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 13px;
border-radius: 999px;
background: var(--white);
border: 1px solid var(--line);
box-shadow: var(--sh-1);
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
}
.account-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 3px rgba(47, 158, 111, 0.18);
}
/* ---------- Progress ---------- */
.progress {
margin-bottom: 18px;
}
.steps {
list-style: none;
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
padding: 0;
}
.step {
display: flex;
align-items: center;
gap: 9px;
flex: 1;
min-width: 0;
}
.step::after {
content: "";
flex: 1;
height: 2px;
border-radius: 2px;
background: var(--line-2);
transition: background 0.3s ease;
}
.step:last-child::after {
display: none;
}
.step:last-child {
flex: 0 0 auto;
}
.step-dot {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 27px;
height: 27px;
border-radius: 50%;
font-size: 12.5px;
font-weight: 700;
color: var(--muted);
background: var(--white);
border: 2px solid var(--line-2);
transition: all 0.25s ease;
}
.step-label {
font-size: 13px;
font-weight: 600;
color: var(--muted);
white-space: nowrap;
transition: color 0.25s ease;
}
.step.is-active .step-dot,
.step.is-done .step-dot {
color: var(--white);
background: var(--brand);
border-color: var(--brand);
box-shadow: 0 0 0 4px var(--brand-50);
}
.step.is-active .step-label,
.step.is-done .step-label {
color: var(--ink);
}
.step.is-done .step-dot {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-soft);
font-size: 0;
}
.step.is-done .step-dot::after {
content: "✓";
font-size: 13px;
}
.step.is-done + .step::before,
.step.is-active::after,
.step.is-done::after {
background: var(--brand);
}
.progress-track {
height: 6px;
border-radius: 999px;
background: var(--line);
overflow: hidden;
}
.progress-fill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--accent));
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ---------- Card ---------- */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 26px 26px 18px;
}
.panel {
animation: fade 0.32s ease;
}
.panel[hidden] {
display: none;
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.panel-head {
margin-bottom: 20px;
}
.panel-kicker {
display: inline-block;
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand);
margin-bottom: 8px;
}
.panel-title {
margin: 0 0 6px;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
}
.panel-lead {
margin: 0;
color: var(--ink-2);
font-size: 14.5px;
}
/* ---------- Step 1: reasons ---------- */
.reasons {
border: 0;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.reason {
display: block;
cursor: pointer;
}
.reason input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.reason-body {
display: flex;
align-items: center;
gap: 13px;
padding: 13px 15px;
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
background: var(--white);
transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.reason:hover .reason-body {
border-color: var(--brand);
background: var(--brand-50);
}
.reason input:focus-visible + .reason-body {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.reason input:checked + .reason-body {
border-color: var(--brand);
background: var(--brand-50);
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.14);
}
.reason-ico {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 11px;
color: var(--brand);
background: var(--brand-50);
transition: color 0.18s ease, background 0.18s ease;
}
.reason input:checked + .reason-body .reason-ico {
color: var(--white);
background: var(--brand);
}
.reason-text {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.reason-text strong {
font-size: 14.5px;
font-weight: 700;
}
.reason-text small {
font-size: 12.5px;
color: var(--muted);
}
.reason-check {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--white);
background: var(--brand);
opacity: 0;
transform: scale(0.5);
transition: all 0.18s ease;
}
.reason input:checked + .reason-body .reason-check {
opacity: 1;
transform: scale(1);
}
/* ---------- Step 2: offers ---------- */
.offer {
position: relative;
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
padding: 20px 18px 18px;
background: var(--white);
animation: fade 0.3s ease;
}
.offer[hidden] {
display: none;
}
.offer-flag {
position: absolute;
top: -11px;
left: 18px;
padding: 4px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--white);
background: var(--brand);
box-shadow: var(--sh-1);
}
.offer-flag-deal {
background: linear-gradient(135deg, var(--accent), #029086);
}
.offer-flag-pause {
background: var(--warn);
}
.offer-flag-soft {
background: var(--ink-2);
}
.offer-grid {
display: flex;
gap: 15px;
align-items: flex-start;
}
.offer-ico {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 14px;
color: var(--brand);
background: var(--brand-50);
}
.offer-ico-deal {
color: var(--accent);
background: var(--accent-soft);
}
.offer-ico-pause {
color: var(--warn);
background: rgba(217, 138, 43, 0.14);
}
.offer-ico-soft {
color: var(--ink-2);
background: rgba(58, 64, 96, 0.1);
}
.offer-copy {
flex: 1;
min-width: 0;
}
.offer-name {
margin: 0 0 6px;
font-size: 17px;
font-weight: 800;
letter-spacing: -0.01em;
}
.offer-desc {
margin: 0 0 12px;
font-size: 14px;
color: var(--ink-2);
}
.offer-desc s {
color: var(--muted);
}
.offer-feats {
list-style: none;
margin: 0 0 14px;
padding: 0;
display: grid;
gap: 7px;
}
.offer-feats li {
display: flex;
align-items: center;
gap: 9px;
font-size: 13.5px;
color: var(--ink-2);
}
.tick {
display: grid;
place-items: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
color: var(--white);
background: var(--ok);
}
.offer-price {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 10px;
}
.offer-now {
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
}
.offer-per {
font-size: 14px;
font-weight: 600;
color: var(--muted);
}
.offer-was {
font-size: 13px;
color: var(--muted);
text-decoration: line-through;
}
.offer-save {
padding: 3px 9px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
color: var(--ok);
background: rgba(47, 158, 111, 0.12);
}
/* Pause picker */
.pause-picker {
display: flex;
gap: 8px;
margin: 4px 0 12px;
}
.pause-opt {
flex: 1;
padding: 9px 10px;
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--white);
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
transition: all 0.16s ease;
}
.pause-opt:hover {
border-color: var(--warn);
}
.pause-opt.is-active {
color: var(--ink);
border-color: var(--warn);
background: rgba(217, 138, 43, 0.12);
box-shadow: 0 0 0 3px rgba(217, 138, 43, 0.14);
}
.pause-note {
margin: 0;
font-size: 12.5px;
color: var(--muted);
}
/* ---------- Step 3: confirm ---------- */
.summary {
display: grid;
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
margin-bottom: 18px;
}
.summary-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 15px;
background: var(--white);
font-size: 13.5px;
}
.summary-k {
color: var(--muted);
}
.summary-v {
color: var(--ink);
font-weight: 600;
text-align: right;
}
.feedback {
display: block;
margin-bottom: 16px;
}
.feedback-label {
display: block;
font-size: 13.5px;
font-weight: 600;
margin-bottom: 7px;
}
.feedback-opt {
font-weight: 400;
color: var(--muted);
}
.feedback textarea {
width: 100%;
resize: vertical;
min-height: 86px;
padding: 12px 14px;
border: 1.5px solid var(--line-2);
border-radius: var(--r-md);
font: inherit;
font-size: 14px;
color: var(--ink);
background: var(--white);
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.feedback textarea::placeholder {
color: var(--muted);
}
.feedback textarea:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.14);
}
.feedback-count {
display: block;
text-align: right;
margin-top: 5px;
font-size: 11.5px;
color: var(--muted);
}
.warn-note {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 13px 15px;
border-radius: var(--r-md);
background: rgba(212, 80, 62, 0.08);
border: 1px solid rgba(212, 80, 62, 0.2);
color: var(--ink-2);
font-size: 13px;
}
.warn-note svg {
flex: 0 0 auto;
color: var(--danger);
margin-top: 1px;
}
.warn-note strong {
color: var(--danger);
}
/* ---------- Footer actions ---------- */
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.actions-right {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 11px 18px;
border: 1.5px solid transparent;
border-radius: var(--r-md);
font-size: 14px;
font-weight: 700;
letter-spacing: -0.01em;
transition: transform 0.12s ease, background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, color 0.16s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
color: var(--white);
background: linear-gradient(135deg, var(--brand), var(--brand-d));
box-shadow: 0 4px 14px rgba(91, 91, 240, 0.32);
}
.btn-primary:hover {
box-shadow: 0 6px 18px rgba(91, 91, 240, 0.42);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.btn-ghost {
color: var(--ink-2);
background: var(--white);
border-color: var(--line-2);
}
.btn-ghost:hover {
border-color: var(--ink-2);
background: var(--bg);
}
.btn-text {
color: var(--ink-2);
background: transparent;
padding: 11px 8px;
}
.btn-text:hover {
color: var(--ink);
}
.btn-text.danger {
color: var(--muted);
}
.btn-text.danger:hover {
color: var(--danger);
}
.btn-danger {
color: var(--white);
background: var(--danger);
box-shadow: 0 4px 14px rgba(212, 80, 62, 0.3);
}
.btn-danger:hover {
background: #c0432f;
box-shadow: 0 6px 18px rgba(212, 80, 62, 0.4);
}
.btn[hidden] {
display: none;
}
/* ---------- Outcome screens ---------- */
.outcome {
text-align: center;
padding: 40px 28px 32px;
animation: pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.outcome[hidden] {
display: none;
}
@keyframes pop {
from {
opacity: 0;
transform: scale(0.94);
}
to {
opacity: 1;
transform: none;
}
}
.outcome-ico {
display: grid;
place-items: center;
width: 78px;
height: 78px;
margin: 0 auto 18px;
border-radius: 50%;
}
.outcome-ico-ok {
color: var(--ok);
background: rgba(47, 158, 111, 0.14);
}
.outcome-ico-bye {
color: var(--muted);
background: rgba(108, 115, 147, 0.12);
}
.outcome-title {
margin: 0 0 8px;
font-size: 23px;
font-weight: 800;
letter-spacing: -0.02em;
}
.outcome-lead {
margin: 0 auto 18px;
max-width: 38ch;
color: var(--ink-2);
font-size: 14.5px;
}
.outcome-receipt {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 9px 16px;
margin-bottom: 22px;
border-radius: 999px;
background: var(--bg);
border: 1px solid var(--line);
font-size: 13px;
}
.outcome-receipt-k {
color: var(--muted);
}
.outcome-receipt-v {
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.outcome-actions {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
}
/* ---------- Toasts ---------- */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 60;
width: max-content;
max-width: calc(100vw - 32px);
}
.toast {
display: flex;
align-items: center;
gap: 9px;
padding: 11px 15px;
border-radius: var(--r-md);
background: var(--ink);
color: var(--white);
font-size: 13.5px;
font-weight: 500;
box-shadow: var(--sh-2);
animation: toastIn 0.26s ease;
}
.toast.is-out {
animation: toastOut 0.26s ease forwards;
}
.toast-ico {
display: grid;
place-items: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
color: var(--accent);
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateY(12px);
}
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.page {
padding: 20px 14px 56px;
}
.card {
padding: 22px 18px 16px;
border-radius: var(--r-md);
}
.account-pill {
display: none;
}
.step-label {
display: none;
}
.step::after {
min-width: 14px;
}
.panel-title {
font-size: 20px;
}
.offer-grid {
flex-direction: column;
gap: 12px;
}
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.actions-right {
flex-direction: column-reverse;
align-items: stretch;
margin-left: 0;
}
.actions .btn {
width: 100%;
}
.btn-text {
order: 2;
}
.summary-row {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.summary-v {
text-align: left;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
/* ---------- Data ---------- */
// Maps a cancel reason to the retention offer it should surface.
var REASONS = {
price: {
label: "Too expensive",
offer: "discount",
title: "How about 40% off?",
lead: "Price is the only blocker? Keep everything you have for less.",
accept: "We dropped your bill to $29/mo for the next 3 months.",
savedTitle: "Discount applied",
savedLead: "You'll pay $29/mo for the next 3 billing cycles. Cancel anytime — no lock-in.",
},
unused: {
label: "Not using it enough",
offer: "pause",
title: "Take a break instead",
lead: "No need to cancel — pause your plan and pick it up when you're ready.",
accept: "Your subscription is paused.",
savedTitle: "Subscription paused",
savedLead: "Billing is frozen and your workspace is safe. We'll resume automatically when your pause ends.",
},
temporary: {
label: "Just need a break",
offer: "pause",
title: "Pause, don't cancel",
lead: "Step away for a while and come back to everything exactly where you left it.",
accept: "Your subscription is paused.",
savedTitle: "Subscription paused",
savedLead: "Billing is frozen and your workspace is safe. We'll resume automatically when your pause ends.",
},
missing: {
label: "Missing a feature",
offer: "handoff",
title: "Tell us what's missing",
lead: "There's a good chance it's already on the roadmap — or coming sooner than you think.",
accept: "A specialist will reach out, and your free month is on.",
savedTitle: "We're on it",
savedLead: "A product specialist will email you within one business day, and your next month of Pro is free.",
handoffName: "Talk to a product specialist",
handoffDesc: "A 15-minute call might cover the gap you're hitting — plus a free month of Pro while we work on it.",
},
switching: {
label: "Switching tools",
offer: "handoff",
title: "Before you migrate…",
lead: "We can match what pulled you away — and make the move back painless if it doesn't stick.",
accept: "We'll keep your data warm and send a free month.",
savedTitle: "We'll keep things warm",
savedLead: "Your workspace and data stay intact for 90 days, and your next month of Pro is free if you stay.",
handoffName: "Let's keep you covered",
handoffDesc: "Tell us what the other tool does better. We'll keep your data exportable for 90 days and add a free month.",
},
};
var PRICES = { 1: "July 13, 2026", 2: "August 13, 2026", 3: "September 13, 2026" };
/* ---------- State ---------- */
var state = { step: 0, reason: null, pauseMonths: 1 };
var TOTAL = 3;
/* ---------- Elements ---------- */
var $ = function (sel, ctx) { return (ctx || document).querySelector(sel); };
var $$ = function (sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); };
var stepsEl = $$(".step");
var progressFill = $("#progressFill");
var progressTrack = $(".progress-track");
var panels = $$(".panel");
var actions = $("#actions");
var backBtn = $("#backBtn");
var nextBtn = $("#nextBtn");
var declineBtn = $("#declineBtn");
var acceptBtn = $("#acceptBtn");
var confirmCancelBtn = $("#confirmCancelBtn");
var offerTitle = $("#offerTitle");
var offerLead = $("#offerLead");
var summaryReason = $("#summaryReason");
var feedback = $("#feedback");
var feedbackCount = $("#feedbackCount");
var card = $(".card");
var progressNav = $(".progress");
var savedScreen = $("#savedScreen");
var goodbyeScreen = $("#goodbyeScreen");
/* ---------- Toast ---------- */
var toastWrap = $("#toastWrap");
function toast(msg, kind) {
var t = document.createElement("div");
t.className = "toast";
var icon = kind === "warn"
? '<path d="M12 9v4"/><path d="M12 17h.01"/><circle cx="12" cy="12" r="9"/>'
: '<path d="m4 12 5 5L20 6"/>';
t.innerHTML =
'<span class="toast-ico" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' +
icon + "</svg></span><span>" + msg + "</span>";
toastWrap.appendChild(t);
setTimeout(function () {
t.classList.add("is-out");
t.addEventListener("animationend", function () { t.remove(); });
}, 2600);
}
/* ---------- Render step ---------- */
function renderProgress() {
var pct = Math.round(((state.step + 1) / TOTAL) * 100);
progressFill.style.width = pct + "%";
progressTrack.setAttribute("aria-valuenow", String(pct));
stepsEl.forEach(function (s, i) {
s.classList.toggle("is-active", i === state.step);
s.classList.toggle("is-done", i < state.step);
if (i === state.step) s.setAttribute("aria-current", "step");
else s.removeAttribute("aria-current");
});
}
function showPanel(idx) {
panels.forEach(function (p) {
var n = Number(p.getAttribute("data-panel"));
p.hidden = n !== idx;
});
}
function renderActions() {
backBtn.hidden = state.step === 0;
nextBtn.hidden = state.step !== 0;
declineBtn.hidden = state.step !== 1;
acceptBtn.hidden = state.step !== 1;
confirmCancelBtn.hidden = state.step !== 2;
nextBtn.disabled = state.step === 0 && !state.reason;
}
function render() {
showPanel(state.step);
renderProgress();
renderActions();
actions.hidden = false;
progressNav.hidden = false;
card.hidden = false;
savedScreen.hidden = true;
goodbyeScreen.hidden = true;
// Move focus to the panel heading for screen-reader continuity.
var heading = panels[state.step] && panels[state.step].querySelector(".panel-title");
if (heading) {
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: false });
}
}
/* ---------- Offer rendering (step 2) ---------- */
function renderOffer() {
var cfg = REASONS[state.reason];
offerTitle.textContent = cfg.title;
offerLead.textContent = cfg.lead;
$$(".offer").forEach(function (o) {
o.hidden = o.getAttribute("data-offer") !== cfg.offer;
});
if (cfg.offer === "handoff") {
$("#handoffName").textContent = cfg.handoffName;
$("#handoffDesc").textContent = cfg.handoffDesc;
}
if (cfg.offer === "pause") {
updatePauseDate();
}
}
function updatePauseDate() {
var el = $("#pauseDate");
if (el) el.textContent = PRICES[state.pauseMonths];
}
/* ---------- Reason selection (step 1) ---------- */
$$('input[name="reason"]').forEach(function (input) {
input.addEventListener("change", function () {
state.reason = input.value;
nextBtn.disabled = false;
});
});
/* ---------- Pause picker ---------- */
var pausePicker = $(".pause-picker");
if (pausePicker) {
var opts = $$(".pause-opt", pausePicker);
opts.forEach(function (opt) {
opt.addEventListener("click", function () {
opts.forEach(function (o) {
o.classList.remove("is-active");
o.setAttribute("aria-checked", "false");
});
opt.classList.add("is-active");
opt.setAttribute("aria-checked", "true");
state.pauseMonths = Number(opt.getAttribute("data-months"));
updatePauseDate();
});
});
// Arrow-key support for the radiogroup.
pausePicker.addEventListener("keydown", function (e) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
var i = opts.indexOf(document.activeElement);
if (i < 0) i = opts.findIndex(function (o) { return o.classList.contains("is-active"); });
var next = e.key === "ArrowRight" ? (i + 1) % opts.length : (i - 1 + opts.length) % opts.length;
opts[next].focus();
opts[next].click();
});
}
/* ---------- Feedback counter ---------- */
feedback.addEventListener("input", function () {
feedbackCount.textContent = feedback.value.length + " / 400";
});
/* ---------- Navigation ---------- */
function goTo(step) {
state.step = Math.max(0, Math.min(TOTAL - 1, step));
if (state.step === 1) renderOffer();
if (state.step === 2) summaryReason.textContent = REASONS[state.reason].label;
render();
}
nextBtn.addEventListener("click", function () {
if (!state.reason) {
toast("Pick a reason to continue", "warn");
return;
}
goTo(1);
});
backBtn.addEventListener("click", function () {
goTo(state.step - 1);
});
declineBtn.addEventListener("click", function () {
goTo(2);
});
/* ---------- Accept offer -> saved state ---------- */
acceptBtn.addEventListener("click", function () {
var cfg = REASONS[state.reason];
// Mark all steps complete on the indicator.
stepsEl.forEach(function (s) { s.classList.add("is-done"); s.classList.remove("is-active"); s.removeAttribute("aria-current"); });
progressFill.style.width = "100%";
progressTrack.setAttribute("aria-valuenow", "100");
card.hidden = true;
goodbyeScreen.hidden = true;
$("#savedTitle").textContent = cfg.savedTitle;
$("#savedLead").textContent = cfg.savedLead;
savedScreen.hidden = false;
savedScreen.querySelector(".outcome-title").setAttribute("tabindex", "-1");
savedScreen.querySelector(".outcome-title").focus();
toast(cfg.accept);
});
/* ---------- Confirm cancel -> goodbye state ---------- */
confirmCancelBtn.addEventListener("click", function () {
stepsEl.forEach(function (s) { s.classList.add("is-done"); s.classList.remove("is-active"); s.removeAttribute("aria-current"); });
progressFill.style.width = "100%";
progressTrack.setAttribute("aria-valuenow", "100");
var ref = "NW-CXL-" + String(Math.floor(1000 + Math.random() * 8999));
$("#cancelRef").textContent = ref;
card.hidden = true;
savedScreen.hidden = true;
goodbyeScreen.hidden = false;
goodbyeScreen.querySelector(".outcome-title").setAttribute("tabindex", "-1");
goodbyeScreen.querySelector(".outcome-title").focus();
toast("Plan cancelled · ref " + ref, "warn");
});
/* ---------- Outcome actions (restart flow) ---------- */
function restart() {
state = { step: 0, reason: null, pauseMonths: 1 };
$$('input[name="reason"]').forEach(function (i) { i.checked = false; });
feedback.value = "";
feedbackCount.textContent = "0 / 400";
if (pausePicker) {
$$(".pause-opt", pausePicker).forEach(function (o, idx) {
o.classList.toggle("is-active", idx === 0);
o.setAttribute("aria-checked", idx === 0 ? "true" : "false");
});
}
stepsEl.forEach(function (s) { s.classList.remove("is-done"); });
render();
}
$("#savedDone").addEventListener("click", function () {
toast("Back to billing settings");
restart();
});
$("#goodbyeDone").addEventListener("click", function () {
toast("Returned to billing settings");
restart();
});
$("#reactivateBtn").addEventListener("click", function () {
toast("Welcome back — Pro reactivated");
restart();
});
/* ---------- Esc returns to billing from outcome screens ---------- */
document.addEventListener("keydown", function (e) {
if (e.key !== "Escape") return;
if (!savedScreen.hidden || !goodbyeScreen.hidden) {
restart();
}
});
/* ---------- Init ---------- */
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind — Cancel / downgrade flow</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2 4 6v6c0 5 3.5 8 8 10 4.5-2 8-5 8-10V6z" />
<path d="m9 12 2 2 4-4" />
</svg>
</span>
<div class="brand-text">
<strong>Northwind</strong>
<span>Billing & plan</span>
</div>
</div>
<span class="account-pill" aria-hidden="true">
<span class="account-dot"></span>
Pro plan · $49/mo
</span>
</header>
<section class="cancel" aria-label="Cancellation flow">
<!-- Progress indicator -->
<nav class="progress" aria-label="Cancellation progress">
<ol class="steps" id="steps">
<li class="step is-active" data-step="0" aria-current="step">
<span class="step-dot" aria-hidden="true">1</span>
<span class="step-label">Reason</span>
</li>
<li class="step" data-step="1">
<span class="step-dot" aria-hidden="true">2</span>
<span class="step-label">Offer</span>
</li>
<li class="step" data-step="2">
<span class="step-dot" aria-hidden="true">3</span>
<span class="step-label">Confirm</span>
</li>
</ol>
<div class="progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="33" aria-label="Cancellation progress">
<span class="progress-fill" id="progressFill" style="width:33%"></span>
</div>
</nav>
<div class="card">
<!-- STEP 1 — Reason -->
<section class="panel is-active" data-panel="0" aria-label="Cancel reason">
<div class="panel-head">
<span class="panel-kicker">Step 1 of 3</span>
<h1 class="panel-title">We're sorry to see you go</h1>
<p class="panel-lead">Before you cancel, help us understand what's not working. Your answer tailors the options on the next screen.</p>
</div>
<fieldset class="reasons" id="reasons">
<legend class="sr-only">Why are you cancelling?</legend>
<label class="reason">
<input type="radio" name="reason" value="price" />
<span class="reason-body">
<span class="reason-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1v22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
</span>
<span class="reason-text">
<strong>It's too expensive</strong>
<small>The price is more than I can justify right now.</small>
</span>
<span class="reason-check" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m4 12 5 5L20 6"/></svg>
</span>
</span>
</label>
<label class="reason">
<input type="radio" name="reason" value="unused" />
<span class="reason-body">
<span class="reason-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
</span>
<span class="reason-text">
<strong>I'm not using it enough</strong>
<small>I signed up but never built it into my routine.</small>
</span>
<span class="reason-check" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m4 12 5 5L20 6"/></svg>
</span>
</span>
</label>
<label class="reason">
<input type="radio" name="reason" value="missing" />
<span class="reason-body">
<span class="reason-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h18"/><path d="M6 7v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7"/><path d="M10 11v6M14 11v6"/><path d="M9 7V4h6v3"/></svg>
</span>
<span class="reason-text">
<strong>Missing a feature I need</strong>
<small>It doesn't do something my team depends on.</small>
</span>
<span class="reason-check" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m4 12 5 5L20 6"/></svg>
</span>
</span>
</label>
<label class="reason">
<input type="radio" name="reason" value="switching" />
<span class="reason-body">
<span class="reason-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="M21 3 13 11"/><path d="M8 21H3v-5"/><path d="M3 21 11 13"/></svg>
</span>
<span class="reason-text">
<strong>Switching to another tool</strong>
<small>I found something that fits my workflow better.</small>
</span>
<span class="reason-check" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m4 12 5 5L20 6"/></svg>
</span>
</span>
</label>
<label class="reason">
<input type="radio" name="reason" value="temporary" />
<span class="reason-body">
<span class="reason-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>
</span>
<span class="reason-text">
<strong>Just need a break</strong>
<small>I'll come back, but I don't need it this month.</small>
</span>
<span class="reason-check" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m4 12 5 5L20 6"/></svg>
</span>
</span>
</label>
</fieldset>
</section>
<!-- STEP 2 — Tailored offer -->
<section class="panel" data-panel="1" aria-label="Retention offer" hidden>
<div class="panel-head">
<span class="panel-kicker">Step 2 of 3</span>
<h1 class="panel-title" id="offerTitle">Before you cancel…</h1>
<p class="panel-lead" id="offerLead">Here's an option that might keep things working for you.</p>
</div>
<!-- Offer: discount -->
<article class="offer" data-offer="discount" hidden>
<span class="offer-flag offer-flag-deal">Limited offer</span>
<div class="offer-grid">
<div class="offer-ico offer-ico-deal" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7h6l5 5-7 7-5-5z" transform="rotate(0)"/><path d="M7.5 2.5 2.5 7.5 11 16l5-5z"/><circle cx="6.5" cy="6.5" r="1.2"/></svg>
</div>
<div class="offer-copy">
<h2 class="offer-name">Stay for 40% off — 3 months</h2>
<p class="offer-desc">Keep every Pro feature for <strong>$29/mo</strong> instead of <s>$49/mo</s> for the next three billing cycles. No commitment after that.</p>
<div class="offer-price">
<span class="offer-now">$29<span class="offer-per">/mo</span></span>
<span class="offer-was">$49/mo</span>
<span class="offer-save">Save $60</span>
</div>
</div>
</div>
</article>
<!-- Offer: downgrade -->
<article class="offer" data-offer="downgrade" hidden>
<span class="offer-flag">Lighter plan</span>
<div class="offer-grid">
<div class="offer-ico offer-ico-plan" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-6"/></svg>
</div>
<div class="offer-copy">
<h2 class="offer-name">Switch to Starter — $19/mo</h2>
<p class="offer-desc">If Pro is more than you need, Starter keeps your projects and history at a lower monthly cost.</p>
<ul class="offer-feats">
<li><span class="tick" aria-hidden="true">✓</span> 10 projects & 5 seats</li>
<li><span class="tick" aria-hidden="true">✓</span> All your existing data stays put</li>
<li><span class="tick" aria-hidden="true">✓</span> Upgrade back to Pro anytime</li>
</ul>
<div class="offer-price">
<span class="offer-now">$19<span class="offer-per">/mo</span></span>
<span class="offer-was">from $49/mo</span>
<span class="offer-save">Save $30/mo</span>
</div>
</div>
</div>
</article>
<!-- Offer: pause -->
<article class="offer" data-offer="pause" hidden>
<span class="offer-flag offer-flag-pause">Pause, don't cancel</span>
<div class="offer-grid">
<div class="offer-ico offer-ico-pause" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>
</div>
<div class="offer-copy">
<h2 class="offer-name">Pause your subscription</h2>
<p class="offer-desc">Take up to <strong>3 months</strong> off. We'll freeze billing and keep your workspace, data, and settings exactly as they are.</p>
<div class="pause-picker" role="radiogroup" aria-label="Pause duration">
<button type="button" class="pause-opt is-active" role="radio" aria-checked="true" data-months="1">1 month</button>
<button type="button" class="pause-opt" role="radio" aria-checked="false" data-months="2">2 months</button>
<button type="button" class="pause-opt" role="radio" aria-checked="false" data-months="3">3 months</button>
</div>
<p class="pause-note">Billing resumes automatically on <strong id="pauseDate">July 13, 2026</strong>.</p>
</div>
</div>
</article>
<!-- Offer: handoff (switching / missing feature) -->
<article class="offer" data-offer="handoff" hidden>
<span class="offer-flag offer-flag-soft">We hear you</span>
<div class="offer-grid">
<div class="offer-ico offer-ico-soft" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12h8"/><path d="M12 8v8"/><circle cx="12" cy="12" r="9"/></svg>
</div>
<div class="offer-copy">
<h2 class="offer-name" id="handoffName">Talk to a product specialist</h2>
<p class="offer-desc" id="handoffDesc">A quick 15-minute call might solve what you're missing — and you'll get a free month while we work on it.</p>
<ul class="offer-feats" id="handoffFeats">
<li><span class="tick" aria-hidden="true">✓</span> One free month of Pro on us</li>
<li><span class="tick" aria-hidden="true">✓</span> Priority access to the roadmap team</li>
</ul>
</div>
</div>
</article>
</section>
<!-- STEP 3 — Confirm + feedback -->
<section class="panel" data-panel="2" aria-label="Confirm cancellation" hidden>
<div class="panel-head">
<span class="panel-kicker">Step 3 of 3</span>
<h1 class="panel-title">Confirm cancellation</h1>
<p class="panel-lead">Your Pro plan will stay active until the end of the current cycle, then switch to the free tier.</p>
</div>
<div class="summary" aria-label="Cancellation summary">
<div class="summary-row">
<span class="summary-k">Plan ending</span>
<span class="summary-v">Northwind Pro · $49/mo</span>
</div>
<div class="summary-row">
<span class="summary-k">Access until</span>
<span class="summary-v"><strong>June 30, 2026</strong></span>
</div>
<div class="summary-row">
<span class="summary-k">Reason given</span>
<span class="summary-v" id="summaryReason">—</span>
</div>
</div>
<label class="feedback">
<span class="feedback-label">Anything we could have done better? <span class="feedback-opt">(optional)</span></span>
<textarea id="feedback" rows="4" maxlength="400" placeholder="Tell us what made you decide to leave…"></textarea>
<span class="feedback-count" id="feedbackCount" aria-live="polite">0 / 400</span>
</label>
<div class="warn-note" role="note">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.3 3.3 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.3a2 2 0 0 0-3.4 0Z"/></svg>
<span>Cancelling removes <strong>2 collaborators</strong> and pauses all scheduled automations after June 30.</span>
</div>
</section>
<!-- Footer nav -->
<footer class="actions" id="actions">
<button type="button" class="btn btn-ghost" id="backBtn" hidden>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
Back
</button>
<div class="actions-right">
<!-- Step 1 -->
<button type="button" class="btn btn-primary" id="nextBtn" disabled>
Continue
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
</button>
<!-- Step 2 -->
<button type="button" class="btn btn-text danger" id="declineBtn" hidden>No thanks, keep cancelling</button>
<button type="button" class="btn btn-primary" id="acceptBtn" hidden>Accept offer</button>
<!-- Step 3 -->
<button type="button" class="btn btn-danger" id="confirmCancelBtn" hidden>Confirm cancellation</button>
</div>
</footer>
</div>
<!-- SAVED state (offer accepted) -->
<div class="card outcome" id="savedScreen" hidden>
<div class="outcome-ico outcome-ico-ok" aria-hidden="true">
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m4 12 5 5L20 6"/></svg>
</div>
<h1 class="outcome-title" id="savedTitle">You're all set</h1>
<p class="outcome-lead" id="savedLead">Your offer is applied and your subscription stays active.</p>
<div class="outcome-actions">
<button type="button" class="btn btn-primary" id="savedDone">Back to billing</button>
</div>
</div>
<!-- GOODBYE state (cancelled) -->
<div class="card outcome" id="goodbyeScreen" hidden>
<div class="outcome-ico outcome-ico-bye" aria-hidden="true">
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</div>
<h1 class="outcome-title">Your plan has been cancelled</h1>
<p class="outcome-lead">You'll keep full Pro access until <strong>June 30, 2026</strong>. After that your workspace moves to the free tier — nothing is deleted.</p>
<div class="outcome-receipt">
<span class="outcome-receipt-k">Confirmation</span>
<span class="outcome-receipt-v" id="cancelRef">NW-CXL-0000</span>
</div>
<div class="outcome-actions">
<button type="button" class="btn btn-primary" id="reactivateBtn">Reactivate Pro</button>
<button type="button" class="btn btn-ghost" id="goodbyeDone">Done</button>
</div>
</div>
</section>
</main>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Cancellation / downgrade flow
A self-contained retention flow for the fictional Northwind product. A segmented progress
indicator tracks three steps — Reason, Offer, Confirm — with aria-current on the active step,
filled checkmarks on completed ones, and a gradient progress bar that advances as you move
forward. Step one is a radiogroup of five realistic cancel reasons (too expensive, not using it,
missing a feature, switching tools, just need a break); Continue stays disabled until one is
chosen.
Step two is the heart of the pattern: the offer adapts to the reason. Price complaints get a 40 percent discount card, low usage and break requests get a pause-subscription card with a 1-to-3-month duration picker that recomputes the resume date, and missing-feature or switching answers get a specialist-handoff card with a free month. Accepting any offer diverts to a saved state and shows a toast; declining drops you into step three. The confirm step summarises the plan, captures optional feedback with a live character counter, and warns about collaborators and automations that will pause.
Choosing “Confirm cancellation” reveals a goodbye screen with a generated reference code and a
reactivate button, while accepting an offer reveals a green saved screen — both can be dismissed
with Esc to return to billing. Headings receive focus on every transition for screen-reader
continuity, the layout collapses to a single stacked column under 520px, and motion is disabled
under prefers-reduced-motion.
Illustrative UI only — fictional brand, plans, and pricing.