SaaS — Billing & Subscription
A finance-grade SaaS billing and subscription page for a fictional analytics product. It pairs a current-plan card showing price, seats, and renewal date with a live usage summary of animated meter bars, a Visa payment-method card, and a downloadable invoice history table. A change-plan dialog reproduces the proration upgrade pattern, an update-payment form validates and reformats card input, and download actions fire contextual toasts. Includes a working light and dark theme toggle, accessible focus traps, and a responsive layout.
MCP
Code
:root {
--bg: #f7f8fb;
--surface: #ffffff;
--surface-2: #fbfcfe;
--ink: #0f1222;
--muted: #646b85;
--brand: #6366f1;
--brand-d: #4f46e5;
--brand-soft: rgba(99, 102, 241, .1);
--ok: #16a34a;
--ok-soft: rgba(22, 163, 74, .12);
--warn: #d97706;
--warn-soft: rgba(217, 119, 6, .14);
--danger: #dc2626;
--line: rgba(15, 18, 34, .1);
--line-2: rgba(15, 18, 34, .06);
--shadow: 0 1px 2px rgba(15, 18, 34, .04), 0 8px 24px rgba(15, 18, 34, .06);
--shadow-lg: 0 12px 40px rgba(15, 18, 34, .18);
--radius: 14px;
--maxw: 1080px;
}
[data-theme="dark"] {
--bg: #0b0d16;
--surface: #14172270;
--surface: #141722;
--surface-2: #191d2b;
--ink: #eef0f7;
--muted: #9aa1bb;
--brand: #818cf8;
--brand-d: #6366f1;
--brand-soft: rgba(129, 140, 248, .16);
--ok: #4ade80;
--ok-soft: rgba(74, 222, 128, .16);
--warn: #fbbf24;
--warn-soft: rgba(251, 191, 36, .16);
--danger: #f87171;
--line: rgba(255, 255, 255, .12);
--line-2: rgba(255, 255, 255, .07);
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 10px 30px rgba(0, 0, 0, .35);
--shadow-lg: 0 16px 50px rgba(0, 0, 0, .6);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background .2s ease, color .2s ease;
}
h1, h2, h3 { margin: 0; line-height: 1.25; letter-spacing: -.01em; }
button { font-family: inherit; }
.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;
}
.skip-link {
position: absolute; left: 12px; top: -50px; z-index: 100;
background: var(--brand); color: #fff; padding: 8px 14px; border-radius: 8px;
text-decoration: none; font-weight: 600; transition: top .15s ease;
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
/* ---------- Topbar ---------- */
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: 18px;
padding: 12px clamp(16px, 4vw, 40px);
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: saturate(160%) blur(10px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 10px; font-size: 15px; }
.brand-mark {
display: grid; place-items: center; width: 32px; height: 32px;
border-radius: 9px; color: #fff;
background: linear-gradient(135deg, var(--brand), var(--brand-d));
box-shadow: 0 4px 12px var(--brand-soft);
}
.brand-name { color: var(--ink); font-weight: 500; }
.brand-name b { font-weight: 700; }
.topnav { display: flex; gap: 4px; margin-left: 12px; }
.topnav a {
padding: 7px 12px; border-radius: 8px; text-decoration: none;
color: var(--muted); font-size: 14px; font-weight: 500;
}
.topnav a:hover { background: var(--line-2); color: var(--ink); }
.topnav a[aria-current="page"] { color: var(--brand-d); background: var(--brand-soft); font-weight: 600; }
[data-theme="dark"] .topnav a[aria-current="page"] { color: var(--brand); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.icon-btn {
display: grid; place-items: center; min-width: 36px; height: 36px;
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 9px; cursor: pointer; font-size: 15px; padding: 0 8px;
transition: background .15s ease, transform .1s ease;
}
.icon-btn:hover { background: var(--surface-2); }
.icon-btn:active { transform: scale(.95); }
.avatar {
display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%;
background: linear-gradient(135deg, #f472b6, #8b5cf6); color: #fff;
font-size: 12px; font-weight: 700; letter-spacing: .02em;
}
/* ---------- Layout ---------- */
.wrap {
max-width: var(--maxw); margin: 0 auto;
padding: clamp(20px, 4vw, 40px) clamp(16px, 4vw, 40px) 64px;
}
.page-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; flex-wrap: wrap; margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 6px; font-size: 12px; font-weight: 600; letter-spacing: .06em;
text-transform: uppercase; color: var(--brand-d);
}
[data-theme="dark"] .eyebrow { color: var(--brand); }
.page-head h1 { font-size: clamp(24px, 4vw, 30px); font-weight: 800; }
.lede { margin: 8px 0 0; color: var(--muted); max-width: 52ch; font-size: 14.5px; }
.status-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 7px 13px; border-radius: 999px; font-size: 13px; font-weight: 600;
border: 1px solid var(--line); background: var(--surface);
}
.status-pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, var(--line)); background: var(--ok-soft); }
.status-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px var(--ok-soft); }
.grid {
display: grid; gap: 18px; margin-bottom: 18px;
grid-template-columns: 1.15fr 1.15fr .9fr;
}
/* ---------- Cards ---------- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 22px; box-shadow: var(--shadow);
display: flex; flex-direction: column;
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 16px;
}
.card-head h2 { font-size: 16px; font-weight: 700; }
.muted-note { color: var(--muted); font-size: 12.5px; }
.hint { margin: 16px 0 0; font-size: 12.5px; color: var(--muted); }
.hint.secure { display: flex; align-items: center; gap: 6px; }
.hint b { color: var(--ink); font-weight: 600; }
.badge {
display: inline-block; padding: 3px 9px; border-radius: 999px;
font-size: 11.5px; font-weight: 700; letter-spacing: .01em; white-space: nowrap;
}
.badge-brand { background: var(--brand-soft); color: var(--brand-d); }
[data-theme="dark"] .badge-brand { color: var(--brand); }
.badge-ok { background: var(--ok-soft); color: var(--ok); }
.badge-warn { background: var(--warn-soft); color: var(--warn); }
/* Plan card */
.plan-card { grid-column: span 1; }
.plan-price { margin: 2px 0 16px; font-size: 38px; font-weight: 800; letter-spacing: -.02em; }
.plan-price .per { font-size: 15px; font-weight: 600; color: var(--muted); margin-left: 4px; }
.plan-meta { display: grid; gap: 0; margin: 0; border-top: 1px solid var(--line-2); }
.plan-meta div {
display: flex; align-items: center; justify-content: space-between;
padding: 11px 0; border-bottom: 1px solid var(--line-2);
}
.plan-meta dt { color: var(--muted); font-size: 13.5px; margin: 0; }
.plan-meta dd { margin: 0; font-size: 13.5px; font-weight: 500; }
.plan-meta dd b { font-weight: 700; }
.plan-actions { display: flex; gap: 10px; margin-top: 18px; flex-wrap: wrap; }
/* Buttons */
.btn {
border: 1px solid transparent; border-radius: 10px; padding: 10px 16px;
font-size: 14px; font-weight: 600; cursor: pointer; line-height: 1.2;
transition: background .15s ease, transform .08s ease, border-color .15s ease;
}
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--brand); color: #fff; box-shadow: 0 4px 14px var(--brand-soft); }
.btn-primary:hover { background: var(--brand-d); }
.btn-ghost { background: var(--surface); color: var(--ink); border-color: var(--line); }
.btn-ghost:hover { background: var(--surface-2); border-color: var(--muted); }
.btn.full { width: 100%; margin-top: auto; }
.btn.sm { padding: 7px 12px; font-size: 13px; }
.link-btn {
background: none; border: 0; padding: 0; cursor: pointer;
color: var(--brand-d); font-weight: 600; font-size: inherit;
text-decoration: none;
}
[data-theme="dark"] .link-btn { color: var(--brand); }
.link-btn:hover { text-decoration: underline; }
/* Usage */
.usage-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 16px; }
.usage-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 7px; }
.usage-label { font-size: 13.5px; font-weight: 600; }
.usage-val { font-size: 12.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
.meter {
height: 8px; border-radius: 999px; background: var(--line-2); overflow: hidden;
}
.meter-fill {
display: block; height: 100%; width: var(--pct); border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--brand-d));
transition: width .8s cubic-bezier(.22, 1, .36, 1);
}
.meter-fill.warn { background: linear-gradient(90deg, #f59e0b, var(--warn)); }
.meter-fill.ok { background: linear-gradient(90deg, #34d399, var(--ok)); }
/* Payment */
.pay-card { grid-column: span 1; }
.pay-method {
display: flex; align-items: center; gap: 14px;
padding: 14px; border: 1px solid var(--line); border-radius: 12px;
background: var(--surface-2); margin-bottom: 16px;
}
.card-brand {
position: relative; flex: none; width: 52px; height: 34px; border-radius: 7px;
background: linear-gradient(135deg, #1a1f71, #2b3a9e);
display: flex; align-items: center; justify-content: flex-end; padding-right: 7px; gap: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .12);
}
.vc-dot { width: 16px; height: 16px; border-radius: 50%; }
.vc-dot.a { background: #eb001b; }
.vc-dot.b { background: #f79e1b; margin-left: -7px; mix-blend-mode: screen; opacity: .92; }
.pay-line { margin: 0; font-size: 14.5px; }
.pay-line b { font-weight: 700; }
.pay-info .muted-note { margin: 2px 0 0; display: block; }
/* Invoices */
.invoices-card { padding-bottom: 8px; }
.table-scroll { overflow-x: auto; margin: 0 -22px -8px; padding: 0 22px; }
.inv-table { width: 100%; border-collapse: collapse; min-width: 560px; }
.inv-table th, .inv-table td {
text-align: left; padding: 13px 12px; font-size: 13.5px;
border-bottom: 1px solid var(--line-2);
}
.inv-table th {
font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); font-weight: 600;
}
.inv-table tbody tr { transition: background .12s ease; }
.inv-table tbody tr:hover { background: var(--surface-2); }
.inv-table tbody tr:last-child td { border-bottom: 0; }
.inv-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.row-action { text-align: right; }
.mono { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 13px; }
/* ---------- Modal ---------- */
.modal-overlay {
position: fixed; inset: 0; z-index: 50;
display: flex; align-items: center; justify-content: center; padding: 18px;
background: rgba(8, 10, 20, .5); backdrop-filter: blur(3px);
animation: fade .18s ease;
}
[data-theme="dark"] .modal-overlay { background: rgba(0, 0, 0, .62); }
@keyframes fade { from { opacity: 0; } }
.modal {
width: min(460px, 100%); max-height: 90vh; overflow-y: auto;
background: var(--surface); border: 1px solid var(--line);
border-radius: 18px; padding: 24px; box-shadow: var(--shadow-lg);
animation: pop .22s cubic-bezier(.22, 1, .36, 1);
}
@keyframes pop { from { transform: translateY(14px) scale(.97); opacity: 0; } }
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.modal-head h2 { font-size: 18px; font-weight: 700; }
.modal-sub { margin: 8px 0 18px; color: var(--muted); font-size: 13.5px; }
.plan-options { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; }
.plan-opt {
display: block; border: 1px solid var(--line); border-radius: 12px;
padding: 14px; cursor: pointer; transition: border-color .15s, background .15s;
position: relative;
}
.plan-opt:hover { border-color: var(--muted); }
.plan-opt input { position: absolute; opacity: 0; pointer-events: none; }
.plan-opt:has(input:checked) {
border-color: var(--brand); background: var(--brand-soft);
box-shadow: 0 0 0 1px var(--brand);
}
.plan-opt:has(input:focus-visible) { outline: 2px solid var(--brand); outline-offset: 2px; }
.opt-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.opt-top b { font-size: 15px; display: flex; align-items: center; gap: 8px; }
.opt-price { font-size: 15px; font-weight: 700; }
.opt-price small { font-weight: 500; color: var(--muted); }
.opt-desc { display: block; margin-top: 5px; font-size: 12.5px; color: var(--muted); }
.tag {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
padding: 2px 7px; border-radius: 999px; background: var(--line-2); color: var(--muted);
}
.tag-pop { background: var(--brand-soft); color: var(--brand-d); }
[data-theme="dark"] .tag-pop { color: var(--brand); }
.modal-foot {
margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--line-2);
display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap;
}
.modal-total { margin: 0; font-size: 13.5px; color: var(--muted); }
.modal-total b { font-size: 19px; color: var(--ink); font-weight: 800; }
.modal-total span { font-size: 12px; }
.modal-btns { display: flex; gap: 10px; margin-left: auto; }
.modal-btns.full-end { width: 100%; justify-content: flex-end; }
/* Form fields */
.field { margin-bottom: 14px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
.field input {
width: 100%; padding: 10px 12px; font-size: 14px; font-family: inherit;
border: 1px solid var(--line); border-radius: 10px;
background: var(--surface-2); color: var(--ink); transition: border-color .15s;
}
.field input::placeholder { color: var(--muted); opacity: .7; }
.field input:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
.field input[aria-invalid="true"] { border-color: var(--danger); }
.err { display: none; color: var(--danger); font-size: 12px; margin-top: 5px; }
.err.show { display: block; }
/* ---------- Toast ---------- */
.toast-region {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
z-index: 80; display: flex; flex-direction: column; gap: 10px; align-items: center;
width: max-content; max-width: calc(100% - 32px); pointer-events: none;
}
.toast {
display: flex; align-items: center; gap: 10px;
background: var(--ink); color: var(--bg);
padding: 12px 16px; border-radius: 11px; font-size: 13.5px; font-weight: 500;
box-shadow: var(--shadow-lg); animation: toastIn .25s cubic-bezier(.22, 1, .36, 1);
}
.toast.out { animation: toastOut .25s ease forwards; }
.toast .t-icon {
display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%;
background: var(--ok); color: #fff; font-size: 12px; flex: none;
}
.toast.info .t-icon { background: var(--brand); }
@keyframes toastIn { from { transform: translateY(16px); opacity: 0; } }
@keyframes toastOut { to { transform: translateY(16px); opacity: 0; } }
/* ---------- Responsive ---------- */
@media (max-width: 880px) {
.grid { grid-template-columns: 1fr 1fr; }
.pay-card { grid-column: span 2; }
}
@media (max-width: 620px) {
.topnav { display: none; }
.grid { grid-template-columns: 1fr; }
.pay-card { grid-column: span 1; }
.table-scroll { overflow: visible; margin: 0; padding: 0; }
.inv-table { min-width: 0; }
.inv-table thead { display: none; }
.inv-table, .inv-table tbody, .inv-table tr, .inv-table td { display: block; width: 100%; }
.inv-table tr {
border: 1px solid var(--line); border-radius: 12px;
padding: 6px 12px; margin-bottom: 10px;
}
.inv-table tbody tr:hover { background: var(--surface); }
.inv-table td {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 0; border-bottom: 1px solid var(--line-2);
}
.inv-table td:last-child { border-bottom: 0; }
.inv-table td::before {
content: attr(data-th); font-size: 11.5px; font-weight: 600;
text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
}
.inv-table .num { text-align: right; }
.row-action { justify-content: flex-end; }
.row-action::before { content: ""; }
}
@media (max-width: 380px) {
.card { padding: 18px; }
.plan-actions .btn { flex: 1; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}(function () {
"use strict";
var $ = function (sel, ctx) { return (ctx || document).querySelector(sel); };
var $$ = function (sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); };
var fmt = function (n) {
return "$" + Number(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
/* ---------- Toast helper ---------- */
var region = $("#toastRegion");
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind === "info" ? " info" : "");
var icon = document.createElement("span");
icon.className = "t-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = kind === "info" ? "i" : "✓";
var text = document.createElement("span");
text.textContent = msg;
el.appendChild(icon);
el.appendChild(text);
region.appendChild(el);
setTimeout(function () {
el.classList.add("out");
el.addEventListener("animationend", function () { el.remove(); });
}, 3200);
}
/* ---------- Theme toggle ---------- */
var themeBtn = $("#themeToggle");
var themeIcon = $(".theme-icon", themeBtn);
function applyTheme(dark) {
document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
themeBtn.setAttribute("aria-pressed", String(dark));
themeIcon.textContent = dark ? "☀️" : "🌙";
}
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
applyTheme(prefersDark);
themeBtn.addEventListener("click", function () {
applyTheme(document.documentElement.getAttribute("data-theme") !== "dark");
});
/* ---------- Modal machinery ---------- */
var lastFocused = null;
function openModal(overlay) {
lastFocused = document.activeElement;
overlay.hidden = false;
document.body.style.overflow = "hidden";
var focusable = overlay.querySelector("input, button, [tabindex]");
if (focusable) focusable.focus();
overlay._trap = function (e) {
if (e.key === "Escape") { closeModal(overlay); return; }
if (e.key !== "Tab") return;
var items = $$("button, input, [tabindex]:not([tabindex='-1'])", overlay)
.filter(function (el) { return !el.disabled && el.offsetParent !== null; });
if (!items.length) return;
var first = items[0], last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
overlay.addEventListener("keydown", overlay._trap);
}
function closeModal(overlay) {
overlay.hidden = true;
document.body.style.overflow = "";
if (overlay._trap) overlay.removeEventListener("keydown", overlay._trap);
if (lastFocused) lastFocused.focus();
}
$$(".modal-overlay").forEach(function (overlay) {
overlay.addEventListener("mousedown", function (e) {
if (e.target === overlay) closeModal(overlay);
});
$$("[data-close]", overlay).forEach(function (b) {
b.addEventListener("click", function () { closeModal(overlay); });
});
});
/* ---------- Plan change modal ---------- */
var planModal = $("#planModal");
var planRadios = $$("input[name='plan']");
var modalTotal = $("#modalTotal");
function syncModalTotal() {
var checked = $("input[name='plan']:checked");
modalTotal.textContent = fmt(checked.dataset.price);
}
planRadios.forEach(function (r) { r.addEventListener("change", syncModalTotal); });
$("#upgradeBtn").addEventListener("click", function () {
// reset selection to current plan
var current = planRadios.filter(function (r) { return r.value === $("#planBadge").textContent.trim(); })[0];
if (current) current.checked = true;
syncModalTotal();
openModal(planModal);
});
$("#confirmPlan").addEventListener("click", function () {
var checked = $("input[name='plan']:checked");
var name = checked.value;
var price = Number(checked.dataset.price);
var seats = Number(checked.dataset.seats);
$("#planBadge").textContent = name;
$("#planPrice").textContent = fmt(price).replace(".00", "");
$("#nextCharge").textContent = fmt(price);
$("#seatMax").textContent = String(seats);
$("#seatCount").textContent = String(Math.min(Number($("#seatCount").textContent), seats));
// update current-plan tag inside modal
$$(".plan-opt").forEach(function (o) { o.classList.remove("is-current"); });
$$(".tag").forEach(function (t) { if (t.textContent === "Current") t.remove(); });
var b = checked.closest(".plan-opt").querySelector(".opt-top b");
if (b && !b.querySelector(".tag")) {
var tag = document.createElement("span");
tag.className = "tag";
tag.textContent = "Current";
b.appendChild(tag);
}
checked.closest(".plan-opt").classList.add("is-current");
closeModal(planModal);
toast("Plan changed to " + name + " — " + fmt(price) + "/mo");
});
/* ---------- Manage seats / add seats ---------- */
function addSeats() {
var max = $("#seatMax");
var newMax = Number(max.textContent) + 5;
max.textContent = String(newMax);
// refresh the seats usage meter
var seatLi = $$("[data-usage]")[0];
if (seatLi) {
var used = Number($("#seatCount").textContent);
var pct = Math.round((used / newMax) * 100);
var fill = seatLi.querySelector(".meter-fill");
fill.style.setProperty("--pct", pct + "%");
fill.classList.remove("warn");
seatLi.querySelector(".usage-val").textContent = used + " / " + newMax;
seatLi.querySelector(".meter").setAttribute("aria-valuenow", String(pct));
}
toast("Added 5 seats — now " + max.textContent + " total", "info");
}
$("#manageSeatsBtn").addEventListener("click", addSeats);
$("#usageUpgrade").addEventListener("click", addSeats);
/* ---------- Payment update form ---------- */
$("#updatePayBtn").addEventListener("click", function () { openModal($("#payModal")); });
var payForm = $("#payForm");
function setErr(input, msg) {
var err = input.parentNode.querySelector("[data-err]");
if (msg) {
input.setAttribute("aria-invalid", "true");
err.textContent = msg; err.classList.add("show");
} else {
input.removeAttribute("aria-invalid");
err.textContent = ""; err.classList.remove("show");
}
}
// live formatting for card number + expiry
var ccNum = $("#ccNum");
ccNum.addEventListener("input", function () {
var digits = ccNum.value.replace(/\D/g, "").slice(0, 16);
ccNum.value = digits.replace(/(.{4})/g, "$1 ").trim();
});
var ccExp = $("#ccExp");
ccExp.addEventListener("input", function () {
var d = ccExp.value.replace(/\D/g, "").slice(0, 4);
ccExp.value = d.length >= 3 ? d.slice(0, 2) + "/" + d.slice(2) : d;
});
$("#ccCvc").addEventListener("input", function (e) {
e.target.value = e.target.value.replace(/\D/g, "").slice(0, 4);
});
payForm.addEventListener("submit", function (e) {
e.preventDefault();
var ok = true;
var name = $("#ccName"), num = $("#ccNum"), exp = $("#ccExp"), cvc = $("#ccCvc");
if (!name.value.trim()) { setErr(name, "Enter the name on the card."); ok = false; } else setErr(name, "");
var rawNum = num.value.replace(/\s/g, "");
if (rawNum.length < 15) { setErr(num, "Enter a valid card number."); ok = false; } else setErr(num, "");
if (!/^\d{2}\/\d{2}$/.test(exp.value)) { setErr(exp, "Use MM/YY."); ok = false; }
else {
var mm = Number(exp.value.slice(0, 2));
if (mm < 1 || mm > 12) { setErr(exp, "Invalid month."); ok = false; } else setErr(exp, "");
}
if (cvc.value.length < 3) { setErr(cvc, "3–4 digits."); ok = false; } else setErr(cvc, "");
if (!ok) {
var firstBad = payForm.querySelector("[aria-invalid='true']");
if (firstBad) firstBad.focus();
return;
}
var last4 = rawNum.slice(-4);
var brand = /^4/.test(rawNum) ? "Visa" : /^5/.test(rawNum) ? "Mastercard" : /^3/.test(rawNum) ? "Amex" : "Card";
$("#cardBrand").textContent = brand;
$("#cardLast4").textContent = last4;
$("#cardExp").textContent = exp.value.replace("/", " / ");
closeModal($("#payModal"));
payForm.reset();
$$("[data-err]", payForm).forEach(function (s) { s.classList.remove("show"); });
$$("input", payForm).forEach(function (i) { i.removeAttribute("aria-invalid"); });
toast("Payment method updated — " + brand + " ••••" + last4);
});
/* ---------- Invoice downloads ---------- */
$$("[data-download]").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.getAttribute("data-download");
btn.textContent = "Downloading…";
btn.disabled = true;
setTimeout(function () {
btn.textContent = "Download";
btn.disabled = false;
toast("Invoice " + id + ".pdf downloaded", "info");
}, 700);
});
});
$("#downloadAllBtn").addEventListener("click", function () {
var n = $$("[data-download]").length;
toast("Preparing " + n + " invoices as a ZIP archive…", "info");
});
/* ---------- Animate usage bars on first paint ---------- */
window.requestAnimationFrame(function () {
$$(".meter-fill").forEach(function (fill) {
var target = fill.style.getPropertyValue("--pct");
fill.style.setProperty("--pct", "0%");
window.requestAnimationFrame(function () {
setTimeout(function () { fill.style.setProperty("--pct", target); }, 80);
});
});
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind Analytics — Billing & Subscription</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>
<a class="skip-link" href="#main">Skip to content</a>
<header class="topbar" role="banner">
<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="M3 17l5-5 4 4 8-9" />
<path d="M16 7h4v4" />
</svg>
</span>
<span class="brand-name">Northwind <b>Analytics</b></span>
</div>
<nav class="topnav" aria-label="Account">
<a href="#" aria-current="page">Billing</a>
<a href="#">Members</a>
<a href="#">Settings</a>
</nav>
<div class="topbar-right">
<button id="themeToggle" class="icon-btn" type="button" aria-pressed="false" title="Toggle theme">
<span class="theme-icon" aria-hidden="true">🌙</span>
<span class="sr-only">Toggle dark mode</span>
</button>
<div class="avatar" title="Maya Chen — Owner" aria-hidden="true">MC</div>
</div>
</header>
<main id="main" class="wrap" role="main">
<header class="page-head">
<div>
<p class="eyebrow">Workspace · Atlas Team</p>
<h1>Billing & Subscription</h1>
<p class="lede">Manage your plan, payment method, and invoices. Changes apply to all members of this workspace.</p>
</div>
<span class="status-pill ok" role="status">
<span class="dot" aria-hidden="true"></span> Account in good standing
</span>
</header>
<div class="grid">
<!-- Current plan -->
<section class="card plan-card" aria-labelledby="plan-h">
<div class="card-head">
<h2 id="plan-h">Current plan</h2>
<span class="badge badge-brand" id="planBadge">Team</span>
</div>
<p class="plan-price"><span id="planPrice">$240</span><span class="per">/mo</span></p>
<dl class="plan-meta">
<div>
<dt>Seats</dt>
<dd><b id="seatCount">12</b> of <span id="seatMax">12</span> used</dd>
</div>
<div>
<dt>Billing cycle</dt>
<dd>Monthly</dd>
</div>
<div>
<dt>Renews on</dt>
<dd id="renewDate">Jul 13, 2026</dd>
</div>
</dl>
<div class="plan-actions">
<button class="btn btn-primary" id="upgradeBtn" type="button">Change plan</button>
<button class="btn btn-ghost" id="manageSeatsBtn" type="button">Manage seats</button>
</div>
<p class="hint">Next charge of <b id="nextCharge">$240.00</b> on <span class="renew-mirror">Jul 13, 2026</span>.</p>
</section>
<!-- Usage summary -->
<section class="card usage-card" aria-labelledby="usage-h">
<div class="card-head">
<h2 id="usage-h">Usage this cycle</h2>
<span class="muted-note">Resets in 28 days</span>
</div>
<ul class="usage-list" id="usageList">
<li data-usage>
<div class="usage-row">
<span class="usage-label">Seats</span>
<span class="usage-val">12 / 12</span>
</div>
<div class="meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100" aria-label="Seats usage">
<span class="meter-fill warn" style="--pct:100%"></span>
</div>
</li>
<li data-usage>
<div class="usage-row">
<span class="usage-label">API calls</span>
<span class="usage-val">3.2M / 5M</span>
</div>
<div class="meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="64" aria-label="API usage">
<span class="meter-fill" style="--pct:64%"></span>
</div>
</li>
<li data-usage>
<div class="usage-row">
<span class="usage-label">Data storage</span>
<span class="usage-val">182 GB / 250 GB</span>
</div>
<div class="meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="73" aria-label="Storage usage">
<span class="meter-fill" style="--pct:73%"></span>
</div>
</li>
<li data-usage>
<div class="usage-row">
<span class="usage-label">Scheduled exports</span>
<span class="usage-val">41 / 100</span>
</div>
<div class="meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="41" aria-label="Exports usage">
<span class="meter-fill ok" style="--pct:41%"></span>
</div>
</li>
</ul>
<p class="hint">You're approaching your seat limit. <button class="link-btn" id="usageUpgrade" type="button">Add seats</button> to avoid invite blocks.</p>
</section>
<!-- Payment method -->
<section class="card pay-card" aria-labelledby="pay-h">
<div class="card-head">
<h2 id="pay-h">Payment method</h2>
</div>
<div class="pay-method">
<span class="card-brand" aria-hidden="true">
<span class="vc-dot a"></span><span class="vc-dot b"></span>
</span>
<div class="pay-info">
<p class="pay-line"><b id="cardBrand">Visa</b> ending in <b id="cardLast4">4242</b></p>
<p class="muted-note">Expires <span id="cardExp">09 / 28</span> · Maya Chen</p>
</div>
</div>
<button class="btn btn-ghost full" id="updatePayBtn" type="button">Update payment method</button>
<p class="hint secure"><span aria-hidden="true">🔒</span> Encrypted & PCI-DSS compliant. We never store full card numbers.</p>
</section>
</div>
<!-- Invoices -->
<section class="card invoices-card" aria-labelledby="inv-h">
<div class="card-head">
<h2 id="inv-h">Invoice history</h2>
<button class="btn btn-ghost sm" id="downloadAllBtn" type="button">Download all</button>
</div>
<div class="table-scroll">
<table class="inv-table">
<caption class="sr-only">Invoice history with date, amount, status, and download action</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Date</th>
<th scope="col" class="num">Amount</th>
<th scope="col">Status</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody id="invBody">
<tr>
<td data-th="Invoice"><span class="mono">INV-2026-006</span></td>
<td data-th="Date">Jun 13, 2026</td>
<td data-th="Amount" class="num">$240.00</td>
<td data-th="Status"><span class="badge badge-ok">Paid</span></td>
<td class="row-action"><button class="link-btn" data-download="INV-2026-006" type="button">Download</button></td>
</tr>
<tr>
<td data-th="Invoice"><span class="mono">INV-2026-005</span></td>
<td data-th="Date">May 13, 2026</td>
<td data-th="Amount" class="num">$240.00</td>
<td data-th="Status"><span class="badge badge-ok">Paid</span></td>
<td class="row-action"><button class="link-btn" data-download="INV-2026-005" type="button">Download</button></td>
</tr>
<tr>
<td data-th="Invoice"><span class="mono">INV-2026-004</span></td>
<td data-th="Date">Apr 13, 2026</td>
<td data-th="Amount" class="num">$200.00</td>
<td data-th="Status"><span class="badge badge-ok">Paid</span></td>
<td class="row-action"><button class="link-btn" data-download="INV-2026-004" type="button">Download</button></td>
</tr>
<tr>
<td data-th="Invoice"><span class="mono">INV-2026-003</span></td>
<td data-th="Date">Mar 13, 2026</td>
<td data-th="Amount" class="num">$200.00</td>
<td data-th="Status"><span class="badge badge-warn">Refunded</span></td>
<td class="row-action"><button class="link-btn" data-download="INV-2026-003" type="button">Download</button></td>
</tr>
<tr>
<td data-th="Invoice"><span class="mono">INV-2026-002</span></td>
<td data-th="Date">Feb 13, 2026</td>
<td data-th="Amount" class="num">$200.00</td>
<td data-th="Status"><span class="badge badge-ok">Paid</span></td>
<td class="row-action"><button class="link-btn" data-download="INV-2026-002" type="button">Download</button></td>
</tr>
<tr>
<td data-th="Invoice"><span class="mono">INV-2026-001</span></td>
<td data-th="Date">Jan 13, 2026</td>
<td data-th="Amount" class="num">$200.00</td>
<td data-th="Status"><span class="badge badge-ok">Paid</span></td>
<td class="row-action"><button class="link-btn" data-download="INV-2026-001" type="button">Download</button></td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<!-- Plan change modal -->
<div class="modal-overlay" id="planModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="planModalTitle">
<div class="modal-head">
<h2 id="planModalTitle">Change your plan</h2>
<button class="icon-btn" data-close type="button" aria-label="Close dialog">✕</button>
</div>
<p class="modal-sub">Pick the plan that fits Atlas Team. Upgrades are prorated and take effect immediately.</p>
<fieldset class="plan-options">
<legend class="sr-only">Available plans</legend>
<label class="plan-opt">
<input type="radio" name="plan" value="Starter" data-price="60" data-seats="3" />
<span class="opt-body">
<span class="opt-top"><b>Starter</b><span class="opt-price">$60<small>/mo</small></span></span>
<span class="opt-desc">Up to 3 seats · 1M API calls · community support</span>
</span>
</label>
<label class="plan-opt is-current">
<input type="radio" name="plan" value="Team" data-price="240" data-seats="12" checked />
<span class="opt-body">
<span class="opt-top"><b>Team <span class="tag">Current</span></b><span class="opt-price">$240<small>/mo</small></span></span>
<span class="opt-desc">Up to 12 seats · 5M API calls · priority support</span>
</span>
</label>
<label class="plan-opt">
<input type="radio" name="plan" value="Business" data-price="640" data-seats="40" />
<span class="opt-body">
<span class="opt-top"><b>Business <span class="tag tag-pop">Popular</span></b><span class="opt-price">$640<small>/mo</small></span></span>
<span class="opt-desc">Up to 40 seats · 25M API calls · SSO & audit logs</span>
</span>
</label>
</fieldset>
<div class="modal-foot">
<p class="modal-total">New total: <b id="modalTotal">$240.00</b><span>/mo</span></p>
<div class="modal-btns">
<button class="btn btn-ghost" data-close type="button">Cancel</button>
<button class="btn btn-primary" id="confirmPlan" type="button">Confirm change</button>
</div>
</div>
</div>
</div>
<!-- Update payment modal -->
<div class="modal-overlay" id="payModal" hidden>
<form class="modal" id="payForm" role="dialog" aria-modal="true" aria-labelledby="payModalTitle" novalidate>
<div class="modal-head">
<h2 id="payModalTitle">Update payment method</h2>
<button class="icon-btn" data-close type="button" aria-label="Close dialog">✕</button>
</div>
<p class="modal-sub"><span aria-hidden="true">🔒</span> This is a demo form — no real card data is processed or stored.</p>
<div class="field">
<label for="ccName">Name on card</label>
<input id="ccName" name="ccName" type="text" autocomplete="cc-name" placeholder="Maya Chen" required />
<span class="err" data-err></span>
</div>
<div class="field">
<label for="ccNum">Card number</label>
<input id="ccNum" name="ccNum" type="text" inputmode="numeric" autocomplete="cc-number" placeholder="4242 4242 4242 4242" maxlength="19" required />
<span class="err" data-err></span>
</div>
<div class="field-row">
<div class="field">
<label for="ccExp">Expiry</label>
<input id="ccExp" name="ccExp" type="text" inputmode="numeric" autocomplete="cc-exp" placeholder="MM/YY" maxlength="5" required />
<span class="err" data-err></span>
</div>
<div class="field">
<label for="ccCvc">CVC</label>
<input id="ccCvc" name="ccCvc" type="text" inputmode="numeric" autocomplete="cc-csc" placeholder="123" maxlength="4" required />
<span class="err" data-err></span>
</div>
</div>
<div class="modal-foot">
<div class="modal-btns full-end">
<button class="btn btn-ghost" data-close type="button">Cancel</button>
<button class="btn btn-primary" type="submit">Save card</button>
</div>
</div>
</form>
</div>
<div class="toast-region" id="toastRegion" role="status" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Billing & Subscription
A trustworthy billing dashboard for a fictional analytics workspace. The top of the page anchors three cards on neutral surfaces with a single indigo accent: a current-plan card with the price, seat count, billing cycle, and renewal date; a usage summary of animated meter bars for seats, API calls, storage, and exports; and a payment-method card rendering a Visa brand mark, the last four digits, and expiry. A standing-status pill and a finance-grade encryption note reinforce trust.
Every control is wired in vanilla JavaScript. “Change plan” opens an accessible upgrade modal where radio options recalculate the new monthly total live and confirming updates the plan badge, price, seats, and next-charge line. “Update payment method” opens a form that reformats the card number and expiry as you type, validates each field inline, then swaps in the new brand and last four. The invoice table offers per-row and bulk downloads that show a brief loading state and emit a toast, and “Manage seats” raises the seat ceiling and refreshes its meter.
The shell ships a working light and dark theme toggle that respects the system preference, modals trap focus and close on Escape or backdrop click, and the layout collapses from three columns to one — with the invoice table re-flowing into stacked, labeled cards down to roughly 360px.
Illustrative SaaS UI only — fictional product, metrics, and billing. No real backend.