Upsell — Upgrade modal (plan compare)
A self-contained upsell flow for the fictional Northwind SaaS. A billing settings card shows the current Starter plan with usage bars nudging against their caps, and an Upgrade button opens an accessible modal dialog that places Starter beside the recommended Pro tier. Newly unlocked features are highlighted, a monthly versus annual toggle swaps every price live, and a price-delta bar tallies the extra cost and new total. The confirm button runs a processing then success state before the dialog gracefully closes.
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-sm: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-md: 0 8px 24px rgba(16, 19, 34, 0.08);
--sh-lg: 0 24px 64px rgba(16, 19, 34, 0.18);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
max-width: 880px;
margin: 0 auto;
padding: 0 20px 80px;
}
/* ---------- App chrome ---------- */
.appbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 20px 0;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 9px;
background: linear-gradient(140deg, var(--brand), var(--brand-700));
color: #fff;
box-shadow: var(--sh-sm);
}
.brand-name {
font-size: 17px;
color: var(--ink);
}
.appbar-pill {
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 12px;
}
/* ---------- Settings / billing card ---------- */
.settings {
margin-top: 4px;
}
.billing-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
padding: 26px 26px 22px;
}
.billing-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
margin: 0 0 6px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand);
}
.billing-title {
margin: 0;
font-size: 23px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.2;
}
.cur-plan {
color: var(--brand);
}
.billing-sub {
margin: 8px 0 0;
font-size: 13.5px;
color: var(--muted);
}
.plan-tag {
flex: none;
font-size: 12.5px;
font-weight: 700;
color: var(--ink-2);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 13px;
}
/* ---------- Usage rows ---------- */
.usage {
list-style: none;
margin: 22px 0 0;
padding: 18px 0 0;
border-top: 1px solid var(--line);
display: grid;
gap: 14px;
}
.usage-row {
display: grid;
grid-template-columns: 130px 1fr auto;
align-items: center;
gap: 14px;
}
.usage-label {
font-size: 13.5px;
font-weight: 600;
color: var(--ink-2);
}
.usage-bar {
height: 8px;
border-radius: 999px;
background: var(--brand-50);
overflow: hidden;
}
.usage-fill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--brand-d));
}
.usage-fill.is-warn {
background: linear-gradient(90deg, var(--warn), #c9761f);
}
.usage-num {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--muted);
white-space: nowrap;
}
.usage-num strong {
color: var(--ink);
}
.usage-row.is-cap .usage-num strong {
color: var(--warn);
}
/* ---------- Billing foot / nudge ---------- */
.billing-foot {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.nudge {
margin: 0;
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: var(--ink-2);
max-width: 420px;
}
.nudge-icon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 7px;
color: var(--warn);
background: rgba(217, 138, 43, 0.12);
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-weight: 600;
font-size: 14px;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 10px 16px;
cursor: pointer;
transition: transform 0.12s ease, background 0.15s ease, box-shadow 0.15s ease,
border-color 0.15s ease, color 0.15s ease;
white-space: nowrap;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--brand-50), 0 0 0 4px var(--brand);
}
.btn-lg {
padding: 12px 20px;
font-size: 14.5px;
}
.btn-primary {
background: var(--brand);
color: #fff;
box-shadow: var(--sh-sm);
}
.btn-primary:hover {
background: var(--brand-d);
}
.btn-ghost {
background: var(--white);
color: var(--ink-2);
border-color: var(--line-2);
}
.btn-ghost:hover {
background: var(--bg);
color: var(--ink);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--white);
color: var(--ink-2);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.icon-btn:hover {
background: var(--bg);
color: var(--ink);
}
.icon-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--brand-50), 0 0 0 4px var(--brand);
}
/* ---------- Overlay + modal ---------- */
.overlay {
position: fixed;
inset: 0;
background: rgba(16, 19, 34, 0.46);
backdrop-filter: blur(2px);
z-index: 40;
opacity: 0;
animation: overlayIn 0.22s ease forwards;
}
.overlay.is-closing {
animation: overlayOut 0.18s ease forwards;
}
@keyframes overlayIn {
to {
opacity: 1;
}
}
@keyframes overlayOut {
to {
opacity: 0;
}
}
.modal {
position: fixed;
z-index: 50;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(720px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-lg);
padding: 26px 26px 22px;
opacity: 0;
animation: modalIn 0.26s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.modal.is-closing {
animation: modalOut 0.18s ease forwards;
}
@keyframes modalIn {
from {
opacity: 0;
transform: translate(-50%, calc(-50% + 14px)) scale(0.97);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes modalOut {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
to {
opacity: 0;
transform: translate(-50%, calc(-50% + 10px)) scale(0.98);
}
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.modal-eyebrow {
margin: 0 0 5px;
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--accent);
}
.modal-title {
margin: 0;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
}
.modal-sub {
margin: 7px 0 0;
font-size: 13.5px;
color: var(--muted);
max-width: 440px;
}
/* ---------- Billing cycle toggle ---------- */
.cycle {
display: inline-flex;
margin-top: 20px;
padding: 4px;
gap: 4px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
}
.cycle-opt {
display: inline-flex;
align-items: center;
gap: 7px;
font: inherit;
font-weight: 600;
font-size: 13px;
color: var(--ink-2);
background: transparent;
border: none;
border-radius: 999px;
padding: 7px 15px;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
}
.cycle-opt.is-active {
background: var(--white);
color: var(--ink);
box-shadow: var(--sh-sm);
}
.cycle-opt:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--brand-50), 0 0 0 4px var(--brand);
}
.cycle-save {
font-size: 11px;
font-weight: 700;
color: var(--ok);
background: rgba(47, 158, 111, 0.12);
border-radius: 999px;
padding: 2px 7px;
}
/* ---------- Compare grid ---------- */
.compare {
margin-top: 20px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 12px;
}
.col {
position: relative;
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px 16px 16px;
background: var(--white);
}
.col-current {
background: var(--bg);
}
.col-target {
border-color: var(--brand);
box-shadow: 0 0 0 1px var(--brand), 0 10px 30px rgba(91, 91, 240, 0.18);
background: linear-gradient(180deg, var(--brand-50), var(--white) 58%);
}
.col-flag {
position: absolute;
top: -10px;
left: 16px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
border-radius: 999px;
padding: 3px 9px;
}
.col-flag-current {
color: var(--ink-2);
background: var(--white);
border: 1px solid var(--line-2);
}
.col-flag-rec {
color: #fff;
background: var(--brand);
}
.col-name {
margin: 6px 0 0;
font-size: 18px;
font-weight: 800;
letter-spacing: -0.01em;
}
.col-price {
margin: 6px 0 0;
display: flex;
align-items: baseline;
gap: 3px;
}
.amount {
font-size: 27px;
font-weight: 800;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.per {
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
}
.col-meta {
margin: 6px 0 0;
font-size: 12.5px;
color: var(--muted);
}
.col-features {
list-style: none;
margin: 14px 0 0;
padding: 14px 0 0;
border-top: 1px solid var(--line);
display: grid;
gap: 9px;
}
.feat {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
}
.feat.is-off {
color: var(--muted);
}
.tick {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
}
.tick-on {
color: #fff;
background: var(--accent);
}
.tick-off {
color: var(--muted);
background: var(--bg);
border: 1px solid var(--line);
}
.feat.is-new {
color: var(--ink);
font-weight: 600;
}
.feat.is-new .tick-on {
background: var(--brand);
}
.newtag {
margin-left: auto;
font-size: 10.5px;
font-weight: 700;
color: var(--brand-700);
background: var(--brand-50);
border-radius: 999px;
padding: 2px 7px;
}
/* delta arrow between columns */
.delta {
display: flex;
align-items: center;
justify-content: center;
}
.delta-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--white);
border: 1px solid var(--line);
color: var(--brand);
box-shadow: var(--sh-sm);
}
/* ---------- Price delta bar ---------- */
.delta-bar {
margin-top: 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
border-radius: var(--r-md);
background: var(--brand-50);
border: 1px solid rgba(91, 91, 240, 0.2);
}
.delta-bar-line {
margin: 0;
font-size: 13.5px;
color: var(--ink-2);
}
.delta-plus {
font-weight: 800;
color: var(--brand-700);
font-variant-numeric: tabular-nums;
}
.delta-per {
font-size: 12px;
font-weight: 600;
}
.delta-bar-sub {
margin: 3px 0 0;
font-size: 12px;
color: var(--muted);
}
.delta-bar-total {
text-align: right;
flex: none;
}
.total-label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.total-amount {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
color: var(--ink);
}
.total-per {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
/* ---------- Modal actions ---------- */
.modal-actions {
margin-top: 18px;
display: flex;
gap: 10px;
}
.modal-actions .btn-ghost {
flex: none;
}
.btn-confirm {
flex: 1;
position: relative;
padding: 12px 20px;
font-size: 14.5px;
min-height: 44px;
}
.spinner {
display: none;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
animation: spin 0.7s linear infinite;
}
.btn-confirm.is-processing {
pointer-events: none;
}
.btn-confirm.is-processing .spinner {
display: inline-block;
}
.btn-confirm.is-done {
background: var(--ok);
pointer-events: none;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.modal-foot {
margin: 14px 0 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
/* ---------- Toasts ---------- */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
z-index: 60;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 9px;
background: var(--ink);
color: #fff;
font-size: 13px;
font-weight: 500;
padding: 11px 16px;
border-radius: 999px;
box-shadow: var(--sh-md);
animation: toastIn 0.26s cubic-bezier(0.16, 1, 0.3, 1);
}
.toast.is-out {
animation: toastOut 0.24s ease forwards;
}
.toast .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateY(8px);
}
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.page {
padding: 0 14px 64px;
}
.billing-card {
padding: 20px 16px 18px;
}
.billing-head {
flex-direction: column;
}
.plan-tag {
align-self: flex-start;
}
.billing-title {
font-size: 20px;
}
.usage-row {
grid-template-columns: 96px 1fr auto;
gap: 10px;
}
.usage-label {
font-size: 12.5px;
}
.billing-foot {
flex-direction: column;
align-items: stretch;
}
.billing-foot .btn-lg {
width: 100%;
}
.modal {
padding: 20px 16px 18px;
width: calc(100vw - 24px);
}
.modal-title {
font-size: 20px;
}
/* stack the compare columns and drop the connector arrow */
.compare {
grid-template-columns: 1fr;
}
.delta {
transform: rotate(90deg);
}
.delta-bar {
flex-direction: column;
align-items: flex-start;
}
.delta-bar-total {
text-align: left;
}
.modal-actions {
flex-direction: column-reverse;
}
.modal-actions .btn-ghost,
.btn-confirm {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.overlay,
.modal,
.toast,
.spinner {
animation-duration: 0.001ms !important;
}
.btn {
transition: none;
}
}(function () {
"use strict";
// Monthly base prices; annual is the equivalent /mo rate when billed yearly (~20% off).
var PRICES = {
starter: { monthly: 19, annual: 15 },
pro: { monthly: 49, annual: 39 },
};
var cycle = "monthly";
var upgraded = false;
// ---- element refs ----
var trigger = document.getElementById("upgradeTrigger");
var overlay = document.getElementById("overlay");
var modal = document.getElementById("modal");
var modalClose = document.getElementById("modalClose");
var laterBtn = document.getElementById("laterBtn");
var confirmBtn = document.getElementById("confirmBtn");
var confirmLabel = confirmBtn.querySelector(".confirm-label");
var deltaAmount = document.getElementById("deltaAmount");
var deltaPer = document.getElementById("deltaPer");
var deltaSub = document.getElementById("deltaSub");
var totalAmount = document.getElementById("totalAmount");
var totalPer = document.getElementById("totalPer");
var planTag = document.getElementById("planTag");
var curPlanLabels = document.querySelectorAll(".cur-plan");
var toastWrap = document.getElementById("toastWrap");
var lastFocus = null;
var confirmTimer = null;
// ---- toast helper ----
function toast(msg) {
var el = document.createElement("div");
el.className = "toast";
el.setAttribute("role", "status");
el.innerHTML = '<span class="dot"></span><span></span>';
el.lastElementChild.textContent = msg;
toastWrap.appendChild(el);
setTimeout(function () {
el.classList.add("is-out");
el.addEventListener("animationend", function () {
el.remove();
});
}, 2800);
}
// ---- price / delta rendering ----
function renderPrices() {
document.querySelectorAll("[data-price]").forEach(function (el) {
var key = el.getAttribute("data-price");
el.textContent = "$" + PRICES[key][cycle];
});
var perLabel = cycle === "annual" ? "/mo, billed yearly" : "/mo";
document.querySelectorAll("[data-per]").forEach(function (el) {
el.textContent = perLabel;
});
var diff = PRICES.pro[cycle] - PRICES.starter[cycle];
deltaAmount.textContent = "$" + diff;
totalAmount.textContent = "$" + PRICES.pro[cycle];
var shortPer = cycle === "annual" ? "/mo" : "/mo";
deltaPer.textContent = shortPer;
totalPer.textContent = shortPer;
deltaSub.textContent =
cycle === "annual"
? "Billed yearly today, prorated for the rest of this cycle."
: "Charged today, prorated for the rest of this cycle.";
}
// ---- billing cycle toggle ----
function setCycle(next) {
cycle = next;
document.querySelectorAll(".cycle-opt").forEach(function (b) {
var on = b.getAttribute("data-cycle") === next;
b.classList.toggle("is-active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
renderPrices();
}
// ---- modal open / close ----
function openModal() {
if (upgraded) {
toast("You're already on Pro — enjoy the extra headroom.");
return;
}
lastFocus = document.activeElement;
overlay.hidden = false;
overlay.classList.remove("is-closing");
modal.hidden = false;
modal.classList.remove("is-closing");
// move focus into the dialog
requestAnimationFrame(function () {
modalClose.focus();
});
document.addEventListener("keydown", onKeydown);
}
function closeModal() {
overlay.classList.add("is-closing");
modal.classList.add("is-closing");
var done = false;
function finish() {
if (done) return;
done = true;
overlay.hidden = true;
modal.hidden = true;
overlay.classList.remove("is-closing");
modal.classList.remove("is-closing");
}
modal.addEventListener("animationend", finish, { once: true });
// fallback if animations are disabled (reduced motion)
setTimeout(finish, 220);
document.removeEventListener("keydown", onKeydown);
if (lastFocus && typeof lastFocus.focus === "function") {
lastFocus.focus();
}
}
function onKeydown(e) {
if (e.key === "Escape") {
closeModal();
return;
}
if (e.key === "Tab") {
trapFocus(e);
}
}
function trapFocus(e) {
var focusables = modal.querySelectorAll(
'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
var visible = [];
focusables.forEach(function (el) {
if (el.offsetParent !== null || el === document.activeElement) visible.push(el);
});
if (!visible.length) return;
var first = visible[0];
var last = visible[visible.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
// ---- confirm / processing / success flow ----
function confirmUpgrade() {
if (confirmBtn.classList.contains("is-processing") || upgraded) return;
confirmBtn.classList.add("is-processing");
confirmBtn.setAttribute("aria-busy", "true");
confirmLabel.textContent = "Processing…";
confirmTimer = setTimeout(function () {
confirmBtn.classList.remove("is-processing");
confirmBtn.classList.add("is-done");
confirmBtn.removeAttribute("aria-busy");
confirmLabel.textContent = "Upgraded to Pro ✓";
upgraded = true;
// reflect new state in the underlying settings card
planTag.textContent = "Pro";
curPlanLabels.forEach(function (el) {
el.textContent = "Pro";
});
trigger.textContent = "Manage plan";
toast("You're on Pro — 20 seats and higher limits unlocked.");
setTimeout(closeModal, 1100);
}, 1500);
}
// ---- wiring ----
trigger.addEventListener("click", openModal);
modalClose.addEventListener("click", closeModal);
laterBtn.addEventListener("click", function () {
closeModal();
toast("No rush — your Starter plan stays active.");
});
overlay.addEventListener("click", closeModal);
confirmBtn.addEventListener("click", confirmUpgrade);
document.querySelectorAll(".cycle-opt").forEach(function (b) {
b.addEventListener("click", function () {
setCycle(b.getAttribute("data-cycle"));
});
});
// ---- init ----
setCycle("monthly");
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind — Upgrade Modal (Plan Compare)</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>
<div class="page">
<!-- Product chrome / billing settings card that hosts the Upgrade trigger -->
<header class="appbar">
<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" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 18 0 9 9 0 0 0-18 0Z" />
<path d="m8 12 3 3 5-6" />
</svg>
</span>
<span class="brand-name">Northwind</span>
</div>
<span class="appbar-pill" aria-hidden="true">Workspace settings</span>
</header>
<main class="settings">
<section class="billing-card" aria-labelledby="billingTitle">
<div class="billing-head">
<div>
<p class="eyebrow">Billing & plan</p>
<h1 class="billing-title" id="billingTitle">You're on the <span class="cur-plan">Starter</span> plan</h1>
<p class="billing-sub">Acme Robotics workspace · 4 of 5 seats used · renews June 13, 2026</p>
</div>
<span class="plan-tag" id="planTag">Starter</span>
</div>
<ul class="usage" aria-label="Current usage against Starter limits">
<li class="usage-row">
<span class="usage-label">Projects</span>
<span class="usage-bar"><span class="usage-fill" style="width:90%"></span></span>
<span class="usage-num"><strong>9</strong> / 10</span>
</li>
<li class="usage-row is-cap">
<span class="usage-label">Team seats</span>
<span class="usage-bar"><span class="usage-fill is-warn" style="width:80%"></span></span>
<span class="usage-num"><strong>4</strong> / 5</span>
</li>
<li class="usage-row">
<span class="usage-label">API calls / mo</span>
<span class="usage-bar"><span class="usage-fill" style="width:68%"></span></span>
<span class="usage-num"><strong>34k</strong> / 50k</span>
</li>
</ul>
<div class="billing-foot">
<p class="nudge">
<span class="nudge-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12l1-8.5Z" />
</svg>
</span>
You're close to your Starter limits. Pro doubles your seats and lifts caps.
</p>
<button class="btn btn-primary btn-lg" id="upgradeTrigger" type="button">
<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 19V5" /><path d="m5 12 7-7 7 7" />
</svg>
Upgrade plan
</button>
</div>
</section>
</main>
<!-- Upgrade modal -->
<div class="overlay" id="overlay" hidden></div>
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-describedby="modalSub" hidden>
<div class="modal-head">
<div>
<p class="modal-eyebrow">Recommended upgrade</p>
<h2 class="modal-title" id="modalTitle">Move to Pro</h2>
<p class="modal-sub" id="modalSub">Unlock more seats, higher limits, and the features your team keeps bumping into.</p>
</div>
<button class="icon-btn" id="modalClose" type="button" aria-label="Close upgrade dialog">
<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="m6 6 12 12M18 6 6 18" />
</svg>
</button>
</div>
<!-- Billing cycle toggle -->
<div class="cycle" role="group" aria-label="Billing cycle">
<button class="cycle-opt is-active" data-cycle="monthly" type="button" aria-pressed="true">Monthly</button>
<button class="cycle-opt" data-cycle="annual" type="button" aria-pressed="false">
Annual <span class="cycle-save">Save 20%</span>
</button>
</div>
<!-- Side-by-side compare -->
<div class="compare">
<!-- Current plan -->
<article class="col col-current" aria-labelledby="colCurName">
<span class="col-flag col-flag-current">Your plan</span>
<h3 class="col-name" id="colCurName">Starter</h3>
<p class="col-price">
<span class="amount" data-price="starter">$19</span><span class="per" data-per>/mo</span>
</p>
<p class="col-meta">For small, scrappy teams.</p>
<ul class="col-features" aria-label="Starter plan includes">
<li class="feat"><span class="tick tick-on" aria-hidden="true">✓</span> 10 projects</li>
<li class="feat"><span class="tick tick-on" aria-hidden="true">✓</span> 5 team seats</li>
<li class="feat"><span class="tick tick-on" aria-hidden="true">✓</span> 50k API calls / mo</li>
<li class="feat is-off"><span class="tick tick-off" aria-hidden="true">–</span> Advanced roles</li>
<li class="feat is-off"><span class="tick tick-off" aria-hidden="true">–</span> Audit log</li>
<li class="feat is-off"><span class="tick tick-off" aria-hidden="true">–</span> Priority support</li>
</ul>
</article>
<!-- Delta connector -->
<div class="delta" aria-hidden="true">
<span class="delta-arrow">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m13 6 6 6-6 6" />
</svg>
</span>
</div>
<!-- Recommended plan -->
<article class="col col-target" aria-labelledby="colTgtName">
<span class="col-flag col-flag-rec">Recommended</span>
<h3 class="col-name" id="colTgtName">Pro</h3>
<p class="col-price">
<span class="amount" data-price="pro">$49</span><span class="per" data-per>/mo</span>
</p>
<p class="col-meta">For teams hitting their limits.</p>
<ul class="col-features" aria-label="Pro plan includes">
<li class="feat is-new"><span class="tick tick-on" aria-hidden="true">✓</span> 50 projects <span class="newtag">+40</span></li>
<li class="feat is-new"><span class="tick tick-on" aria-hidden="true">✓</span> 20 team seats <span class="newtag">+15</span></li>
<li class="feat is-new"><span class="tick tick-on" aria-hidden="true">✓</span> 500k API calls / mo</li>
<li class="feat is-new"><span class="tick tick-on" aria-hidden="true">✓</span> Advanced roles <span class="newtag">New</span></li>
<li class="feat is-new"><span class="tick tick-on" aria-hidden="true">✓</span> Audit log <span class="newtag">New</span></li>
<li class="feat is-new"><span class="tick tick-on" aria-hidden="true">✓</span> Priority support <span class="newtag">New</span></li>
</ul>
</article>
</div>
<!-- Price delta summary -->
<div class="delta-bar" aria-live="polite">
<div class="delta-bar-copy">
<p class="delta-bar-line">
<span class="delta-plus">+<span id="deltaAmount">$30</span><span class="delta-per" id="deltaPer">/mo</span></span>
more than Starter
</p>
<p class="delta-bar-sub" id="deltaSub">Charged today, prorated for the rest of this cycle.</p>
</div>
<div class="delta-bar-total">
<span class="total-label">New total</span>
<span class="total-amount"><span id="totalAmount">$49</span><span class="total-per" id="totalPer">/mo</span></span>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" id="laterBtn" type="button">Maybe later</button>
<button class="btn btn-primary btn-confirm" id="confirmBtn" type="button">
<span class="confirm-label">Upgrade to Pro</span>
<span class="spinner" aria-hidden="true"></span>
</button>
</div>
<p class="modal-foot">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="11" width="16" height="9" rx="2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" />
</svg>
Secure checkout · cancel anytime · prices in USD
</p>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
</div>
<script src="script.js"></script>
</body>
</html>Upgrade modal (plan compare)
A complete in-product upsell for the fictional Northwind SaaS, art-directed in the neutral product palette with an indigo brand and a teal accent. A billing settings card frames the moment: the workspace is on Starter, usage bars for projects, seats, and API calls press against their caps (the seat row tinted amber), and a short nudge explains why Pro is the right move. An “Upgrade plan” button opens the dialog.
The modal (role="dialog", aria-modal, labelled and described, focus trap, Esc and overlay-click to
close) places the current Starter plan side by side with the recommended Pro tier, joined by an arrow
connector. Pro’s rows are highlighted as newly unlocked with +40, +15, and New badges, while
Starter’s locked features are dimmed. A Monthly/Annual segmented toggle swaps every price live and a
“Save 20%” badge marks the annual option; a live price-delta bar tallies how much more Pro costs and the
new total, both recomputed on each cycle change.
Confirming runs a realistic flow — the button shows a spinner and “Processing…”, then settles into a
green “Upgraded to Pro” success state, updates the underlying settings card to Pro, fires a toast, and
gently closes the dialog. Everything is vanilla HTML, CSS, and JS with inline SVG icons — no frameworks,
no build step, and no network requests beyond the Inter web font. A prefers-reduced-motion block
neutralizes the open/close and spinner animations, and the compare columns stack into a single column
below 520px.
Illustrative UI only — the brand, plans, prices, and usage numbers are fictional; not a real product.