Upsell — Add-on / cross-sell at checkout
A self-contained checkout summary for the fictional Northwind Cloud Pro plan, art-directed in a neutral product-UI palette with Inter type and soft shadows. Optional add-on cards — extra seats with a stepper, a Recommended priority-support tier, extra storage and advanced analytics — toggle on and off as accent-tinted cards. Each change live-updates an order summary, count-up animating the subtotal, tax and sticky total, while a toast confirms every selection. Fully keyboard operable and responsive down to 360px.
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-lg: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button {
font: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: var(--r-sm);
}
/* ============ PAGE ============ */
.page {
max-width: 1040px;
margin: 0 auto;
padding: clamp(28px, 5vw, 64px) clamp(16px, 4vw, 32px) 96px;
}
.page__head {
max-width: 620px;
margin: 0 auto 36px;
text-align: center;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 12px 5px 10px;
background: var(--brand-50);
color: var(--brand-700);
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.01em;
}
.page__title {
margin: 16px 0 10px;
font-size: clamp(26px, 4.5vw, 36px);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.12;
}
.page__sub {
margin: 0;
color: var(--muted);
font-size: 15.5px;
}
.page__sub strong {
color: var(--ink-2);
font-weight: 600;
}
/* ============ LAYOUT ============ */
.layout {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 28px;
align-items: start;
}
/* ============ BASE PLAN ============ */
.baseplan {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 16px;
align-items: start;
padding: 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-sm);
margin-bottom: 26px;
}
.baseplan__dot {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, var(--brand), var(--brand-d));
color: #fff;
box-shadow: 0 4px 12px rgba(91, 91, 240, 0.32);
}
.baseplan__brand {
margin: 0 0 1px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--brand-700);
}
.baseplan__name {
margin: 0 0 6px;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.01em;
}
.baseplan__period {
font-size: 13px;
font-weight: 500;
color: var(--muted);
}
.baseplan__desc {
margin: 0;
font-size: 13.5px;
color: var(--muted);
}
.baseplan__price {
text-align: right;
white-space: nowrap;
}
.baseplan__amount {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
}
.baseplan__per {
font-size: 13px;
color: var(--muted);
}
/* ============ SECTION HEAD ============ */
.section-head {
margin-bottom: 14px;
}
.section-head__title {
margin: 0 0 2px;
font-size: 16px;
font-weight: 700;
}
.section-head__hint {
margin: 0;
font-size: 13px;
color: var(--muted);
}
/* ============ ADD-ON LIST ============ */
.addon-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 12px;
}
.addon {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 14px;
align-items: start;
padding: 16px 18px;
background: var(--surface);
border: 1.5px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-sm);
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease,
background 0.18s ease;
}
.addon:hover {
border-color: var(--line-2);
box-shadow: var(--sh-lg);
transform: translateY(-1px);
}
.addon.is-on {
border-color: var(--brand);
background: linear-gradient(0deg, var(--brand-50), var(--surface) 64%);
box-shadow:
var(--sh-sm),
0 0 0 1px var(--brand) inset;
}
.addon--rec.is-on {
border-color: var(--accent);
background: linear-gradient(0deg, var(--accent-soft), var(--surface) 64%);
box-shadow:
var(--sh-sm),
0 0 0 1px var(--accent) inset;
}
/* Checkbox */
.addon__select {
display: block;
padding-top: 2px;
}
.addon__check {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.addon__box {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border: 2px solid var(--line-2);
border-radius: 7px;
background: var(--white);
color: #fff;
transition:
background 0.16s ease,
border-color 0.16s ease,
transform 0.1s ease;
}
.addon__box svg {
opacity: 0;
transform: scale(0.5);
transition:
opacity 0.14s ease,
transform 0.14s ease;
}
.addon__check:checked + .addon__box {
background: var(--brand);
border-color: var(--brand);
}
.addon--rec .addon__check:checked + .addon__box {
background: var(--accent);
border-color: var(--accent);
}
.addon__check:checked + .addon__box svg {
opacity: 1;
transform: scale(1);
}
.addon__check:focus-visible + .addon__box {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.addon__select:active .addon__box {
transform: scale(0.92);
}
/* Add-on body */
.addon__main {
display: flex;
gap: 13px;
min-width: 0;
}
.addon__icon {
flex: none;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 11px;
background: var(--brand-50);
color: var(--brand-d);
}
.addon--rec .addon__icon {
background: var(--accent-soft);
color: var(--accent);
}
.addon__text {
min-width: 0;
}
.addon__titlerow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.addon__title {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.addon__desc {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.addon__price {
text-align: right;
white-space: nowrap;
padding-top: 1px;
}
.addon__amount {
font-size: 17px;
font-weight: 800;
letter-spacing: -0.02em;
}
.addon__unit {
display: block;
font-size: 11.5px;
color: var(--muted);
font-weight: 500;
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px 3px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.badge--rec {
background: var(--accent);
color: #fff;
}
/* ============ STEPPER ============ */
.stepper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
padding: 8px 8px 8px 13px;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-sm);
animation: stepIn 0.22s ease;
}
@keyframes stepIn {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.stepper[hidden] {
display: none;
}
.stepper__label {
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
}
.stepper__controls {
display: flex;
align-items: center;
gap: 4px;
}
.stepper__btn {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid var(--line-2);
border-radius: 8px;
background: var(--white);
color: var(--brand-d);
transition:
background 0.14s ease,
border-color 0.14s ease,
transform 0.08s ease;
}
.stepper__btn:hover {
background: var(--brand-50);
border-color: var(--brand);
}
.stepper__btn:active {
transform: scale(0.9);
}
.stepper__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background: var(--white);
border-color: var(--line);
}
.stepper__value {
min-width: 30px;
text-align: center;
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* ============ SUMMARY ============ */
.summary {
position: sticky;
top: 24px;
}
.summary__inner {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-lg);
padding: 22px;
}
.summary__title {
margin: 0 0 16px;
font-size: 16px;
font-weight: 700;
}
.summary__lines {
list-style: none;
margin: 0;
padding: 0 0 12px;
border-bottom: 1px solid var(--line);
display: grid;
gap: 10px;
}
.line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: 13.5px;
}
.line--base .line__label {
font-weight: 600;
color: var(--ink);
}
.line__label {
color: var(--ink-2);
min-width: 0;
}
.line__qty {
color: var(--muted);
font-weight: 500;
}
.line__val {
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.line--addon {
animation: lineIn 0.24s ease;
}
@keyframes lineIn {
from {
opacity: 0;
transform: translateX(8px);
}
}
.summary__empty {
margin: 0;
padding: 4px 0 12px;
border-bottom: 1px solid var(--line);
font-size: 13px;
color: var(--muted);
}
.totals {
margin: 0;
padding: 14px 0 18px;
display: grid;
gap: 9px;
}
.totals__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: 13.5px;
color: var(--ink-2);
}
.totals__row dt {
margin: 0;
}
.totals__row dd {
margin: 0;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.totals__sub {
color: var(--muted);
font-weight: 500;
}
.totals__row--grand {
margin-top: 5px;
padding-top: 13px;
border-top: 1px solid var(--line);
align-items: center;
color: var(--ink);
font-size: 14px;
font-weight: 700;
}
.totals__row--grand dd {
font-weight: 800;
}
.totals__cur {
font-size: 15px;
font-weight: 700;
vertical-align: 3px;
margin-right: 1px;
}
.totals__amount {
font-size: 26px;
letter-spacing: -0.02em;
}
.totals__per {
font-size: 13px;
color: var(--muted);
font-weight: 500;
}
/* CTA */
.cta {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 18px;
border: none;
border-radius: var(--r-md);
background: linear-gradient(135deg, var(--brand), var(--brand-d));
color: #fff;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.01em;
box-shadow: 0 6px 18px rgba(91, 91, 240, 0.34);
transition:
transform 0.12s ease,
box-shadow 0.16s ease,
filter 0.16s ease;
}
.cta:hover {
filter: brightness(1.05);
box-shadow: 0 9px 24px rgba(91, 91, 240, 0.42);
transform: translateY(-1px);
}
.cta:active {
transform: translateY(0) scale(0.99);
}
.cta.is-busy {
pointer-events: none;
opacity: 0.82;
}
.summary__fine {
display: flex;
align-items: center;
gap: 7px;
margin: 14px 0 0;
font-size: 12px;
color: var(--muted);
}
.summary__fine svg {
flex: none;
color: var(--ok);
}
/* Pulse used when a total changes */
.is-bumped {
animation: bump 0.34s ease;
}
@keyframes bump {
30% {
transform: scale(1.08);
color: var(--brand-d);
}
}
/* ============ TOAST ============ */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
display: flex;
align-items: center;
gap: 9px;
max-width: calc(100vw - 32px);
padding: 12px 18px;
background: var(--ink);
color: #fff;
border-radius: var(--r-md);
box-shadow: var(--sh-lg);
font-size: 13.5px;
font-weight: 500;
opacity: 0;
pointer-events: none;
transition:
opacity 0.24s ease,
transform 0.24s ease;
z-index: 50;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
.toast::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
/* ============ RESPONSIVE ============ */
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
.summary {
position: static;
}
}
@media (max-width: 520px) {
.page {
padding: 24px 16px 80px;
}
.baseplan {
grid-template-columns: auto 1fr;
row-gap: 4px;
}
.baseplan__price {
grid-column: 1 / -1;
text-align: left;
padding-left: 58px;
}
.addon {
grid-template-columns: auto 1fr;
row-gap: 12px;
padding: 14px;
}
.addon__price {
grid-column: 2 / -1;
text-align: left;
display: flex;
align-items: baseline;
gap: 6px;
padding-left: 53px;
}
.addon__unit {
display: inline;
}
.stepper {
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(() => {
"use strict";
// ---- Pricing model (fictional Northwind Cloud) -------------------------
const BASE_PRICE = 99; // Pro plan / mo
const TAX_RATE = 0.085; // 8.5%
const SEAT_MIN = 1;
const SEAT_MAX = 25;
const ADDONS = {
seats: { label: "Extra seats", unit: 12, perSeat: true },
support: { label: "Priority support", unit: 29 },
storage: { label: "Extra storage · 500 GB", unit: 8 },
analytics: { label: "Advanced analytics", unit: 19 },
};
let seatCount = 2; // additional seats when the seats add-on is active
// ---- Element refs -------------------------------------------------------
const list = document.getElementById("addonList");
const items = Array.from(list.querySelectorAll(".addon"));
const summaryLines = document.getElementById("summaryLines");
const summaryEmpty = document.getElementById("summaryEmpty");
const seatCountEl = document.getElementById("seatCount");
const subtotalEl = document.querySelector("[data-subtotal]");
const taxEl = document.querySelector("[data-tax]");
const totalEl = document.querySelector("[data-total]");
const ctaTotalEl = document.querySelector("[data-cta-total]");
const checkoutBtn = document.getElementById("checkoutBtn");
// ---- Helpers ------------------------------------------------------------
const money = (n) =>
"$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function bump(el) {
if (reduceMotion || !el) return;
el.classList.remove("is-bumped");
// force reflow so the animation can replay
void el.offsetWidth;
el.classList.add("is-bumped");
}
// Smoothly count an element's number from its current value to `target`.
function animateNumber(el, target, prefix = "$") {
if (!el) return;
const from = parseFloat((el.textContent || "0").replace(/[^0-9.]/g, "")) || 0;
if (reduceMotion || Math.abs(target - from) < 0.005) {
el.textContent = prefix + target.toFixed(2);
return;
}
const start = performance.now();
const dur = 360;
function tick(now) {
const t = Math.min(1, (now - start) / dur);
const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
el.textContent = prefix + (from + (target - from) * eased).toFixed(2);
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
// ---- Toast --------------------------------------------------------------
const toastEl = document.getElementById("toast");
let toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.hidden = false;
requestAnimationFrame(() => toastEl.classList.add("is-show"));
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastEl.classList.remove("is-show");
setTimeout(() => (toastEl.hidden = true), 240);
}, 2400);
}
// ---- Per-add-on amount --------------------------------------------------
function addonAmount(key) {
const def = ADDONS[key];
if (def.perSeat) return def.unit * seatCount;
return def.unit;
}
function isOn(key) {
const cb = document.getElementById("addon-" + key);
return cb && cb.checked;
}
// ---- Render the summary line list --------------------------------------
function renderLines() {
// Remove previously injected add-on lines (keep the base line).
summaryLines.querySelectorAll(".line--addon").forEach((n) => n.remove());
let anyAddon = false;
Object.keys(ADDONS).forEach((key) => {
if (!isOn(key)) return;
anyAddon = true;
const def = ADDONS[key];
const li = document.createElement("li");
li.className = "line line--addon";
li.dataset.lineAddon = key;
const label = document.createElement("span");
label.className = "line__label";
label.textContent = def.label;
if (def.perSeat) {
const qty = document.createElement("span");
qty.className = "line__qty";
qty.textContent = ` × ${seatCount}`;
label.appendChild(qty);
}
const val = document.createElement("span");
val.className = "line__val";
val.textContent = money(addonAmount(key));
li.append(label, val);
summaryLines.appendChild(li);
});
summaryEmpty.hidden = anyAddon;
}
// ---- Recompute totals ---------------------------------------------------
function recompute(announce) {
let subtotal = BASE_PRICE;
Object.keys(ADDONS).forEach((key) => {
if (isOn(key)) subtotal += addonAmount(key);
});
const tax = subtotal * TAX_RATE;
const total = subtotal + tax;
renderLines();
animateNumber(subtotalEl, subtotal);
animateNumber(taxEl, tax);
animateNumber(totalEl, total, "");
ctaTotalEl.textContent = money(total);
if (announce) {
bump(totalEl);
bump(ctaTotalEl);
}
}
// ---- Wire add-on toggles ------------------------------------------------
items.forEach((item) => {
const key = item.dataset.addon;
const cb = document.getElementById("addon-" + key);
const stepper = item.querySelector("[data-stepper]");
function syncItem(fromUser) {
const on = cb.checked;
item.classList.toggle("is-on", on);
if (stepper) stepper.hidden = !on;
recompute(fromUser);
if (fromUser) {
const def = ADDONS[key];
toast(on ? `Added ${def.label} to your order` : `Removed ${def.label}`);
}
}
cb.addEventListener("change", () => syncItem(true));
// initial state (support starts checked in markup)
syncItem(false);
});
// ---- Seat stepper -------------------------------------------------------
const seatItem = list.querySelector('[data-addon="seats"]');
const stepBtns = seatItem.querySelectorAll("[data-step]");
function updateSeatButtons() {
stepBtns.forEach((b) => {
const dir = Number(b.dataset.step);
b.disabled = (dir < 0 && seatCount <= SEAT_MIN) || (dir > 0 && seatCount >= SEAT_MAX);
});
}
stepBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const next = seatCount + Number(btn.dataset.step);
if (next < SEAT_MIN || next > SEAT_MAX) return;
seatCount = next;
seatCountEl.textContent = String(seatCount);
bump(seatCountEl);
updateSeatButtons();
// Make sure the add-on is on so the change is reflected, then recompute.
const cb = document.getElementById("addon-seats");
if (!cb.checked) {
cb.checked = true;
seatItem.classList.add("is-on");
seatItem.querySelector("[data-stepper]").hidden = false;
}
recompute(true);
});
});
updateSeatButtons();
// ---- Checkout -----------------------------------------------------------
checkoutBtn.addEventListener("click", () => {
if (checkoutBtn.classList.contains("is-busy")) return;
const label = totalEl.textContent;
const count = Object.keys(ADDONS).filter(isOn).length;
checkoutBtn.classList.add("is-busy");
toast(
count
? `Processing payment of $${label} — ${count} add-on${count > 1 ? "s" : ""} included`
: `Processing payment of $${label} for the Pro plan`,
);
setTimeout(() => {
checkoutBtn.classList.remove("is-busy");
toast("Payment confirmed — welcome to Northwind Pro!");
}, 1600);
});
// ---- Initial paint ------------------------------------------------------
recompute(false);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind — Add-ons at checkout</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="page__head">
<span class="eyebrow">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M6 6h15l-1.5 9h-12z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<circle cx="9" cy="20" r="1.6" fill="currentColor"/>
<circle cx="18" cy="20" r="1.6" fill="currentColor"/>
<path d="M3 4h2l1 2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Checkout
</span>
<h1 class="page__title">Complete your Northwind subscription</h1>
<p class="page__sub">
You're upgrading to the <strong>Pro</strong> plan. Add optional extras now to get the
most out of your workspace — toggle anything off before you pay.
</p>
</header>
<section class="layout">
<!-- ============ ADD-ONS COLUMN ============ -->
<div class="addons">
<!-- Base plan summary -->
<article class="baseplan" aria-label="Selected plan">
<span class="baseplan__dot" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M12 2l2.6 6.3L21 9l-4.8 4.2L17.6 20 12 16.4 6.4 20l1.4-6.8L3 9l6.4-.7z" fill="currentColor"/>
</svg>
</span>
<div class="baseplan__body">
<p class="baseplan__brand">Northwind Cloud</p>
<h2 class="baseplan__name">Pro plan <span class="baseplan__period">· billed monthly</span></h2>
<p class="baseplan__desc">Up to 10 projects, 30-day log retention, usage alerts and priority email support.</p>
</div>
<div class="baseplan__price">
<span class="baseplan__amount">$99</span>
<span class="baseplan__per">/ mo</span>
</div>
</article>
<div class="section-head">
<h2 class="section-head__title">Optional add-ons</h2>
<p class="section-head__hint">Mix and match — everything is prorated to your billing cycle.</p>
</div>
<ul class="addon-list" id="addonList">
<!-- ===== Extra seats (stepper) ===== -->
<li class="addon" data-addon="seats">
<label class="addon__select">
<input class="addon__check" type="checkbox" id="addon-seats" aria-describedby="seats-desc" />
<span class="addon__box" aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13"><path d="M4 12l5 5L20 6" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</label>
<div class="addon__main">
<div class="addon__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<circle cx="9" cy="8" r="3.2" fill="none" stroke="currentColor" stroke-width="1.9"/>
<path d="M3.5 19c0-3 2.6-5 5.5-5s5.5 2 5.5 5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
<circle cx="17" cy="9" r="2.4" fill="none" stroke="currentColor" stroke-width="1.7"/>
<path d="M15 19c0-2.4 1.6-4 3.8-4 1 0 1.9.3 2.7.9" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
</svg>
</div>
<div class="addon__text">
<div class="addon__titlerow">
<h3 class="addon__title">Extra seats</h3>
</div>
<p class="addon__desc" id="seats-desc">Invite teammates beyond the 3 included with Pro. Each seat gets full editor access.</p>
<!-- Seat stepper -->
<div class="stepper" data-stepper hidden>
<span class="stepper__label" id="seatLbl">Additional seats</span>
<div class="stepper__controls" role="group" aria-labelledby="seatLbl">
<button type="button" class="stepper__btn" data-step="-1" aria-label="Remove a seat">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 12h14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
</button>
<output class="stepper__value" id="seatCount" aria-live="polite">2</output>
<button type="button" class="stepper__btn" data-step="1" aria-label="Add a seat">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
</button>
</div>
</div>
</div>
</div>
<div class="addon__price">
<span class="addon__amount">$12</span>
<span class="addon__unit">/ seat · mo</span>
</div>
</li>
<!-- ===== Priority support (recommended) ===== -->
<li class="addon addon--rec" data-addon="support">
<label class="addon__select">
<input class="addon__check" type="checkbox" id="addon-support" aria-describedby="support-desc" checked />
<span class="addon__box" aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13"><path d="M4 12l5 5L20 6" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</label>
<div class="addon__main">
<div class="addon__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M5 11a7 7 0 0114 0v4" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
<rect x="3.5" y="11" width="3.5" height="6" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.7"/>
<rect x="17" y="11" width="3.5" height="6" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.7"/>
<path d="M19 17v1.2a2.5 2.5 0 01-2.5 2.5H13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
</svg>
</div>
<div class="addon__text">
<div class="addon__titlerow">
<h3 class="addon__title">Priority support</h3>
<span class="badge badge--rec">
<svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true"><path d="M12 2l2.6 6.3L21 9l-4.8 4.2L17.6 20 12 16.4 6.4 20l1.4-6.8L3 9l6.4-.7z" fill="currentColor"/></svg>
Recommended
</span>
</div>
<p class="addon__desc" id="support-desc">1-hour first-response SLA, a private Slack channel and a named onboarding specialist.</p>
</div>
</div>
<div class="addon__price">
<span class="addon__amount">$29</span>
<span class="addon__unit">/ mo</span>
</div>
</li>
<!-- ===== Extra storage ===== -->
<li class="addon" data-addon="storage">
<label class="addon__select">
<input class="addon__check" type="checkbox" id="addon-storage" aria-describedby="storage-desc" />
<span class="addon__box" aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13"><path d="M4 12l5 5L20 6" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</label>
<div class="addon__main">
<div class="addon__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<ellipse cx="12" cy="6" rx="7" ry="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/>
<path d="M5 6v6c0 1.4 3.1 2.6 7 2.6s7-1.2 7-2.6V6" fill="none" stroke="currentColor" stroke-width="1.8"/>
<path d="M5 12v6c0 1.4 3.1 2.6 7 2.6s7-1.2 7-2.6v-6" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
</div>
<div class="addon__text">
<div class="addon__titlerow">
<h3 class="addon__title">Extra storage · 500 GB</h3>
</div>
<p class="addon__desc" id="storage-desc">Add 500 GB of object storage for assets, backups and exports on top of your 100 GB.</p>
</div>
</div>
<div class="addon__price">
<span class="addon__amount">$8</span>
<span class="addon__unit">/ mo</span>
</div>
</li>
<!-- ===== Advanced analytics ===== -->
<li class="addon" data-addon="analytics">
<label class="addon__select">
<input class="addon__check" type="checkbox" id="addon-analytics" aria-describedby="analytics-desc" />
<span class="addon__box" aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13"><path d="M4 12l5 5L20 6" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</label>
<div class="addon__main">
<div class="addon__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M4 20V4" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
<path d="M4 20h16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
<rect x="7" y="12" width="3" height="5" rx="1" fill="currentColor"/>
<rect x="12" y="8" width="3" height="9" rx="1" fill="currentColor"/>
<rect x="17" y="5" width="3" height="12" rx="1" fill="currentColor"/>
</svg>
</div>
<div class="addon__text">
<div class="addon__titlerow">
<h3 class="addon__title">Advanced analytics</h3>
</div>
<p class="addon__desc" id="analytics-desc">Funnels, cohort retention and exportable dashboards with 12 months of history.</p>
</div>
</div>
<div class="addon__price">
<span class="addon__amount">$19</span>
<span class="addon__unit">/ mo</span>
</div>
</li>
</ul>
</div>
<!-- ============ ORDER SUMMARY (sticky) ============ -->
<aside class="summary" aria-label="Order summary">
<div class="summary__inner">
<h2 class="summary__title">Order summary</h2>
<ul class="summary__lines" id="summaryLines" aria-live="polite">
<li class="line line--base">
<span class="line__label">Pro plan</span>
<span class="line__val" data-line-base>$99.00</span>
</li>
<!-- add-on lines injected here -->
</ul>
<p class="summary__empty" id="summaryEmpty" hidden>No add-ons selected — just the base plan.</p>
<dl class="totals">
<div class="totals__row">
<dt>Subtotal</dt>
<dd data-subtotal>$128.00</dd>
</div>
<div class="totals__row">
<dt>Tax <span class="totals__sub">(8.5%)</span></dt>
<dd data-tax>$10.88</dd>
</div>
<div class="totals__row totals__row--grand">
<dt>Total due today</dt>
<dd>
<span class="totals__cur">$</span><span class="totals__amount" data-total>138.88</span>
<span class="totals__per">/ mo</span>
</dd>
</div>
</dl>
<button class="cta" type="button" id="checkoutBtn">
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
<rect x="3" y="6" width="18" height="13" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.9"/>
<path d="M3 10h18" fill="none" stroke="currentColor" stroke-width="1.9"/>
</svg>
Pay <span data-cta-total>$138.88</span>
</button>
<p class="summary__fine">
<svg viewBox="0 0 24 24" width="13" height="13" aria-hidden="true"><path d="M6 11V8a6 6 0 0112 0v3" fill="none" stroke="currentColor" stroke-width="1.9"/><rect x="4.5" y="11" width="15" height="9" rx="2.2" fill="none" stroke="currentColor" stroke-width="1.9"/></svg>
Secure checkout · cancel or change add-ons anytime.
</p>
</div>
</aside>
</section>
</main>
<!-- Toast -->
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="script.js"></script>
</body>
</html>Add-on / cross-sell at checkout
A complete checkout panel for Northwind Cloud, a fictional SaaS, built in the neutral product-UI palette with Inter type, hairline borders and soft shadows. A summary card pins the selected Pro base plan at the top, followed by a column of optional add-on cards — extra seats, priority support, extra storage and advanced analytics — each with an inline SVG icon, a short benefit blurb, a checkbox and a price. The priority-support card wears a teal Recommended badge and is pre-selected, and selecting any card tints it with the brand or accent colour.
The add-ons drive a sticky Order summary on the right. Toggling a card injects or removes its line,
recomputes the subtotal, the 8.5% tax and the total, and count-up animates every figure with a brief
pulse so the change is easy to follow. The extra-seats card reveals a stepper when enabled; bumping
the seat count multiplies its line item and updates the × n quantity live. The summary shows an empty
note when only the base plan is selected, and the CTA mirrors the running total (“Pay $138.88”).
Every control is keyboard operable with focus-visible rings, the stepper clamps between 1 and 25 seats
and disables its buttons at the limits, and an aria-live summary plus a small toast announce each
change. The layout collapses to a single column under ~860px and reflows the cards and prices down to
~360px, with a prefers-reduced-motion guard on the animations. Vanilla JS only — no frameworks, no
build step, and no network requests beyond the single Google Fonts link.
Illustrative UI only — Northwind Cloud, its plans, add-ons and prices are fictional.