Dashboard — Marketing (funnels · channels)
A marketing growth dashboard for the fictional Lumenpath, built with inline-SVG charts and zero libraries. A sticky sidebar and topbar frame a date-range and channel filter bar, a four-up KPI row with deltas and sparklines, an interactive Visitors to Paid conversion funnel with per-step rates and hover tooltips, a channel-performance widget pairing animated bars with a spend, CAC and ROAS table, and a sortable campaign list with live, paused and in-review statuses. Filters recompute every figure from a deterministic dataset.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-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;
}
h1,
h2,
p,
ol,
ul {
margin: 0;
}
button {
font: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
/* ============ SHELL ============ */
.shell {
display: grid;
grid-template-columns: 256px 1fr;
min-height: 100vh;
}
/* ============ SIDEBAR ============ */
.sidebar {
background: var(--white);
border-right: 1px solid var(--line);
padding: 18px 16px;
display: flex;
flex-direction: column;
gap: 18px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px 12px;
border-bottom: 1px solid var(--line);
}
.brand-mark {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 10px;
background: linear-gradient(135deg, var(--brand), var(--accent));
color: #fff;
font-size: 18px;
}
.brand-name {
font-weight: 800;
font-size: 17px;
letter-spacing: -0.02em;
}
.side-close {
margin-left: auto;
border: 0;
background: transparent;
color: var(--muted);
font-size: 16px;
display: none;
padding: 4px 8px;
border-radius: var(--r-sm);
}
.nav {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
padding: 14px 8px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: var(--r-sm);
color: var(--ink-2);
text-decoration: none;
font-weight: 500;
font-size: 14px;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: var(--bg);
color: var(--ink);
}
.nav-item.is-active {
background: var(--brand-50);
color: var(--brand-700);
font-weight: 600;
}
.ni-ico {
width: 20px;
text-align: center;
font-size: 14px;
opacity: 0.85;
}
.side-card {
margin-top: auto;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px;
}
.sc-title {
font-size: 12px;
font-weight: 600;
color: var(--ink-2);
}
.sc-bar {
height: 8px;
border-radius: 99px;
background: var(--line-2);
margin: 8px 0 6px;
overflow: hidden;
}
.sc-bar span {
display: block;
height: 100%;
border-radius: 99px;
background: linear-gradient(90deg, var(--brand), var(--accent));
}
.sc-meta {
font-size: 12px;
color: var(--muted);
}
.sc-meta strong {
color: var(--ink);
}
.side-user {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 6px 0;
border-top: 1px solid var(--line);
}
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--brand-50);
color: var(--brand-700);
display: grid;
place-items: center;
font-weight: 700;
font-size: 13px;
}
.su-meta {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.su-meta strong {
font-size: 13px;
}
.su-meta small {
color: var(--muted);
font-size: 12px;
}
/* ============ CONTENT ============ */
.content {
display: flex;
flex-direction: column;
min-width: 0;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 14px;
padding: 14px 28px;
background: rgba(246, 247, 251, 0.86);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.page-head h1 {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.02em;
}
.crumb {
font-size: 12px;
color: var(--muted);
}
.top-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.search {
display: flex;
align-items: center;
gap: 8px;
background: var(--white);
border: 1px solid var(--line);
border-radius: 99px;
padding: 7px 14px;
box-shadow: var(--sh-1);
}
.search .s-ico {
color: var(--muted);
}
.search input {
border: 0;
outline: 0;
background: transparent;
font-size: 14px;
width: 180px;
color: var(--ink);
}
.icon-btn {
position: relative;
width: 38px;
height: 38px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--white);
color: var(--ink-2);
font-size: 16px;
box-shadow: var(--sh-1);
transition: background 0.15s;
}
.icon-btn:hover {
background: var(--bg);
}
.icon-btn .dot {
position: absolute;
top: 7px;
right: 8px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--danger);
border: 2px solid var(--white);
}
.side-open {
display: none;
}
/* ============ MAIN ============ */
.main {
padding: 22px 28px 40px;
display: flex;
flex-direction: column;
gap: 18px;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
}
/* ============ FILTER BAR ============ */
.filterbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 10px 12px;
box-shadow: var(--sh-1);
}
.f-spacer {
flex: 1;
}
.seg {
display: inline-flex;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 99px;
padding: 3px;
}
.seg-btn {
border: 0;
background: transparent;
padding: 6px 14px;
border-radius: 99px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
transition: background 0.15s, color 0.15s;
}
.seg-btn:hover {
color: var(--ink);
}
.seg-btn.is-on {
background: var(--white);
color: var(--brand-700);
box-shadow: var(--sh-1);
}
.select {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 6px 10px;
}
.sel-label {
font-size: 12px;
color: var(--muted);
font-weight: 600;
}
.select select {
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--ink);
}
.btn {
border-radius: var(--r-sm);
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border: 1px solid var(--line);
background: var(--white);
color: var(--ink-2);
transition: background 0.15s, transform 0.06s;
}
.btn:hover {
background: var(--bg);
}
.btn:active {
transform: translateY(1px);
}
.btn.primary {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.btn.primary:hover {
background: var(--brand-d);
}
.btn.ghost {
background: transparent;
}
/* ============ KPI ROW ============ */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.kpis[aria-busy="true"] {
opacity: 0.55;
pointer-events: none;
}
.kpi {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 8px;
}
.kpi-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.kpi-label {
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.menu-btn {
border: 0;
background: transparent;
color: var(--muted);
font-size: 16px;
line-height: 1;
padding: 2px 6px;
border-radius: var(--r-sm);
}
.menu-btn:hover {
background: var(--bg);
color: var(--ink);
}
.kpi-value {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.02em;
}
.kpi-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.delta {
font-size: 13px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 3px;
}
.delta.up {
color: var(--ok);
}
.delta.down {
color: var(--danger);
}
.spark {
width: 96px;
height: 30px;
}
/* ============ GRID ============ */
.grid {
display: grid;
grid-template-columns: 1.15fr 1fr;
gap: 16px;
}
.widget {
padding: 18px 18px 20px;
display: flex;
flex-direction: column;
min-width: 0;
}
.campaign-card {
grid-column: 1 / -1;
}
.w-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.w-title {
font-size: 15px;
font-weight: 700;
}
.w-sub {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
.w-tabs {
display: inline-flex;
gap: 2px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 99px;
padding: 3px;
}
.w-tab {
border: 0;
background: transparent;
padding: 5px 12px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.w-tab.is-on {
background: var(--white);
color: var(--brand-700);
box-shadow: var(--sh-1);
}
/* ============ FUNNEL ============ */
.funnel-wrap {
position: relative;
}
.funnel {
width: 100%;
height: auto;
display: block;
}
.funnel-step {
cursor: pointer;
transition: opacity 0.15s;
}
.funnel-step:hover,
.funnel-step.is-hot {
opacity: 0.92;
}
.funnel-step rect,
.funnel-step polygon {
transition: filter 0.15s;
}
.funnel-step:hover rect,
.funnel-step:hover polygon {
filter: brightness(1.05);
}
.funnel-label {
font-size: 12px;
font-weight: 600;
fill: #fff;
}
.funnel-val {
font-size: 15px;
font-weight: 800;
fill: #fff;
}
.funnel-conv {
font-size: 11px;
font-weight: 700;
fill: var(--ink-2);
}
.funnel-tip {
position: absolute;
background: var(--ink);
color: #fff;
font-size: 12px;
font-weight: 500;
padding: 7px 10px;
border-radius: var(--r-sm);
box-shadow: var(--sh-2);
pointer-events: none;
transform: translate(-50%, -120%);
white-space: nowrap;
z-index: 5;
}
.funnel-tip strong {
font-weight: 800;
}
.funnel-legend {
list-style: none;
padding: 14px 0 0;
margin: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 18px;
}
.fl-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.fl-dot {
width: 10px;
height: 10px;
border-radius: 3px;
flex: none;
}
.fl-name {
color: var(--ink-2);
font-weight: 500;
}
.fl-num {
margin-left: auto;
font-weight: 700;
color: var(--ink);
}
/* ============ CHANNEL BARS + TABLE ============ */
.bars {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.bar-row {
display: grid;
grid-template-columns: 92px 1fr auto;
align-items: center;
gap: 10px;
}
.bar-name {
font-size: 12px;
font-weight: 600;
color: var(--ink-2);
}
.bar-track {
height: 12px;
background: var(--bg);
border-radius: 99px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 99px;
width: 0;
transition: width 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}
.bar-val {
font-size: 12px;
font-weight: 700;
color: var(--ink);
min-width: 48px;
text-align: right;
}
.chan-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-top: auto;
}
.chan-table th {
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
padding: 8px 8px;
border-bottom: 1px solid var(--line);
}
.chan-table td {
padding: 9px 8px;
border-bottom: 1px solid var(--line);
}
.chan-table tr:last-child td {
border-bottom: 0;
}
.chan-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.ch-name {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.ch-swatch {
width: 10px;
height: 10px;
border-radius: 3px;
}
.roas-pill {
display: inline-block;
padding: 2px 8px;
border-radius: 99px;
font-weight: 700;
font-size: 12px;
}
.roas-pill.good {
background: rgba(47, 158, 111, 0.14);
color: var(--ok);
}
.roas-pill.mid {
background: rgba(217, 138, 43, 0.14);
color: var(--warn);
}
.roas-pill.low {
background: rgba(212, 80, 62, 0.14);
color: var(--danger);
}
/* ============ CAMPAIGNS ============ */
.camp-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.camp {
display: grid;
grid-template-columns: 1.6fr 0.8fr 0.7fr 0.7fr 0.6fr;
align-items: center;
gap: 14px;
padding: 12px 6px;
border-bottom: 1px solid var(--line);
}
.camp:last-child {
border-bottom: 0;
}
.camp:hover {
background: var(--bg);
border-radius: var(--r-sm);
}
.camp-name {
display: flex;
flex-direction: column;
min-width: 0;
}
.camp-name strong {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.camp-name small {
font-size: 12px;
color: var(--muted);
}
.camp-metric {
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.camp-metric span {
display: block;
font-size: 11px;
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.camp-metric strong {
font-weight: 700;
}
.status {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
}
.status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.status.live {
background: rgba(47, 158, 111, 0.12);
color: var(--ok);
}
.status.paused {
background: rgba(217, 138, 43, 0.12);
color: var(--warn);
}
.status.review {
background: var(--brand-50);
color: var(--brand-700);
}
/* ============ TOAST ============ */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(8px);
background: var(--ink);
color: #fff;
padding: 11px 18px;
border-radius: 99px;
font-size: 13px;
font-weight: 600;
box-shadow: var(--sh-2);
z-index: 60;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ============ RESPONSIVE ============ */
@media (max-width: 980px) {
.grid {
grid-template-columns: 1fr;
}
.funnel-legend {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 720px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: fixed;
z-index: 50;
width: 264px;
transform: translateX(-100%);
transition: transform 0.22s ease;
box-shadow: var(--sh-2);
}
.shell.nav-open .sidebar {
transform: translateX(0);
}
.side-close {
display: block;
}
.side-open {
display: inline-flex;
align-items: center;
justify-content: center;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
.topbar {
padding: 12px 16px;
}
.main {
padding: 16px 16px 36px;
}
.search input {
width: 120px;
}
.camp {
grid-template-columns: 1fr auto;
grid-template-areas:
"name status"
"metrics metrics";
row-gap: 8px;
}
.camp-name {
grid-area: name;
}
.camp .status {
grid-area: status;
justify-self: end;
}
.camp-metric {
display: inline-block;
}
.camp-metric:nth-child(n + 2):nth-child(-n + 4) {
grid-area: metrics;
margin-right: 18px;
}
}
@media (max-width: 460px) {
.kpis {
grid-template-columns: 1fr;
}
.funnel-legend {
grid-template-columns: 1fr;
}
.search {
display: none;
}
.filterbar {
gap: 8px;
}
.camp-metric:nth-child(4) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}/* ============================================================
* Lumenpath — Marketing dashboard (funnels · channels)
* Vanilla JS. Deterministic synthetic data, no libraries.
* ============================================================ */
(function () {
"use strict";
/* ---------- small utils ---------- */
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var clamp = function (n, lo, hi) { return Math.max(lo, Math.min(hi, n)); };
var fmtInt = function (n) { return Math.round(n).toLocaleString("en-US"); };
var fmtK = function (n) {
if (n >= 1000000) return "$" + (n / 1000000).toFixed(2) + "M";
if (n >= 1000) return "$" + (n / 1000).toFixed(1) + "k";
return "$" + Math.round(n);
};
var fmtMoney = function (n) { return "$" + fmtInt(n); };
var toastEl = $("#toast"), toastT;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.hidden = false;
requestAnimationFrame(function () { toastEl.classList.add("show"); });
clearTimeout(toastT);
toastT = setTimeout(function () {
toastEl.classList.remove("show");
setTimeout(function () { toastEl.hidden = true; }, 220);
}, 2200);
}
/* ---------- deterministic dataset ---------- */
// Each channel: base visitors + stage rates + spend + revenue per range.
// Range multipliers scale volume; rates wobble slightly but stay stable.
var RANGE = {
"7d": { mult: 0.24, label: "last 7 days", prev: "prior week", wob: 0.97 },
"30d": { mult: 1, label: "last 30 days", prev: "prior 30 days", wob: 1 },
"90d": { mult: 3.05, label: "last 90 days", prev: "prior quarter", wob: 1.02 },
"12m": { mult: 12.4, label: "last 12 months", prev: "prior year", wob: 1.05 }
};
var CHANNELS = [
{ id: "organic", name: "Organic", color: "#5b5bf0",
visitors: 18400, rLead: 0.092, rTrial: 0.34, rPaid: 0.41, spend: 12200, revenue: 96400 },
{ id: "paid", name: "Paid search", color: "#00b4a6",
visitors: 11200, rLead: 0.118, rTrial: 0.38, rPaid: 0.36, spend: 64800, revenue: 188300 },
{ id: "social", name: "Social", color: "#7c6cf5",
visitors: 9600, rLead: 0.064, rTrial: 0.29, rPaid: 0.31, spend: 28400, revenue: 61200 },
{ id: "email", name: "Email", color: "#d98a2b",
visitors: 5400, rLead: 0.205, rTrial: 0.47, rPaid: 0.52, spend: 4100, revenue: 74800 }
];
var CAMPAIGNS = [
{ name: "Q3 Always-On Search", channel: "paid", status: "live", spend: 31400, cac: 64, roas: 3.4 },
{ name: "Retarget — Trial Abandon", channel: "paid", status: "live", spend: 8600, cac: 41, roas: 4.8 },
{ name: "Founder Story (YouTube)", channel: "social", status: "live", spend: 14200, cac: 88, roas: 2.1 },
{ name: "Onboarding Drip v4", channel: "email", status: "live", spend: 2100, cac: 19, roas: 9.2 },
{ name: "Comparison Pages SEO", channel: "organic", status: "review", spend: 6800, cac: 33, roas: 5.6 },
{ name: "LinkedIn ABM — Enterprise", channel: "social", status: "paused", spend: 12900, cac: 142, roas: 1.6 },
{ name: "Winback — Churned 90d", channel: "email", status: "live", spend: 1700, cac: 24, roas: 6.4 }
];
var STAGES = [
{ key: "visitors", label: "Visitors", color: "#5b5bf0" },
{ key: "leads", label: "Leads", color: "#5454ed" },
{ key: "trials", label: "Trials", color: "#1aa79b" },
{ key: "paid", label: "Paid", color: "#00b4a6" }
];
/* ---------- state ---------- */
var state = { range: "30d", channel: "all", chanMetric: "roas" };
var liveTimer = null, liveJitter = 0;
/* ---------- derive funnel + channel rollups ---------- */
function compute() {
var r = RANGE[state.range];
var list = state.channel === "all"
? CHANNELS
: CHANNELS.filter(function (c) { return c.id === state.channel; });
var totals = { visitors: 0, leads: 0, trials: 0, paid: 0, spend: 0, revenue: 0 };
var rows = list.map(function (c) {
var visitors = c.visitors * r.mult * r.wob;
var leads = visitors * c.rLead;
var trials = leads * c.rTrial;
var paid = trials * c.rPaid;
var spend = c.spend * r.mult;
var revenue = c.revenue * r.mult;
totals.visitors += visitors;
totals.leads += leads;
totals.trials += trials;
totals.paid += paid;
totals.spend += spend;
totals.revenue += revenue;
return {
id: c.id, name: c.name, color: c.color,
spend: spend, revenue: revenue,
cac: paid > 0 ? spend / paid : 0,
roas: spend > 0 ? revenue / spend : 0
};
});
// apply live jitter to volume metrics so the dashboard "ticks"
var j = 1 + liveJitter;
totals.visitors *= j;
totals.leads *= j;
totals.trials *= j;
totals.paid *= j;
return { totals: totals, rows: rows, range: r };
}
/* ---------- KPIs ---------- */
function sparkPath(seed, up) {
// build a stable little sparkline from a seed
var pts = [], n = 14, v = 16;
for (var i = 0; i < n; i++) {
var w = Math.sin((i + seed) * 0.9) * 5 + Math.cos((i + seed) * 0.4) * 3;
v = clamp(v + w + (up ? -0.4 : 0.5), 4, 28);
pts.push((i / (n - 1)) * 100 + "," + v.toFixed(1));
}
return pts;
}
function renderSpark(svg, up) {
var seed = (svg.getAttribute("data-seed") || "1") * 1;
var pts = sparkPath(seed, up);
var color = up ? "var(--ok)" : "var(--danger)";
var area = "M0,32 L" + pts.join(" L") + " L100,32 Z";
var line = "M" + pts.join(" L");
svg.innerHTML =
'<path d="' + area + '" fill="' + color + '" opacity="0.12"></path>' +
'<path d="' + line + '" fill="none" stroke="' + color + '" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"></path>';
}
function setDelta(el, pct, goodWhenUp) {
var up = pct >= 0;
var good = goodWhenUp ? up : !up;
el.className = "delta " + (good ? "up" : "down");
el.innerHTML = (up ? "▲" : "▼") + " " + Math.abs(pct).toFixed(1) + "%";
return up;
}
function renderKpis(data) {
var t = data.totals;
var conv = t.visitors > 0 ? (t.paid / t.visitors) * 100 : 0;
var cac = t.paid > 0 ? t.spend / t.paid : 0;
var roas = t.spend > 0 ? t.revenue / t.spend : 0;
var map = {
sessions: { val: fmtInt(t.visitors), delta: 8.4, goodUp: true, seed: 2 },
conv: { val: conv.toFixed(2) + "%", delta: 2.1, goodUp: true, seed: 5 },
cac: { val: fmtMoney(cac), delta: -4.7, goodUp: false, seed: 9 },
roas: { val: roas.toFixed(2) + "×", delta: 6.3, goodUp: true, seed: 7 }
};
$$(".kpi").forEach(function (card) {
var k = card.getAttribute("data-kpi");
var m = map[k];
if (!m) return;
$("[data-value]", card).textContent = m.val;
var spark = $("[data-spark]", card);
spark.setAttribute("data-seed", m.seed);
var up = setDelta($("[data-delta]", card), m.delta, m.goodUp);
renderSpark(spark, m.delta >= 0);
});
}
/* ---------- FUNNEL (inline SVG) ---------- */
var funnelEl = $("#funnel");
var funnelTip = $("#funnelTip");
var funnelData = [];
function renderFunnel(data) {
var t = data.totals;
var vals = [t.visitors, t.leads, t.trials, t.paid];
var W = 520, H = 280, padX = 16, padTop = 8;
var rowH = (H - padTop) / 4 - 6;
var max = vals[0] || 1;
var maxW = W - padX * 2;
var ns = "http://www.w3.org/2000/svg";
funnelEl.innerHTML = "";
funnelData = [];
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
var v = vals[i];
var wTop = (vals[i] / max) * maxW;
// bottom edge tapers toward the next stage's width; last step tapers gently
var wBot = i < 3 ? (vals[i + 1] / max) * maxW : wTop * 0.62;
var y = padTop + i * (rowH + 6);
var cx = W / 2;
var x1t = cx - wTop / 2, x2t = cx + wTop / 2;
var x1b = cx - wBot / 2, x2b = cx + wBot / 2;
var conv = i === 0 ? 100 : (vals[i] / vals[i - 1]) * 100;
var g = document.createElementNS(ns, "g");
g.setAttribute("class", "funnel-step");
g.setAttribute("tabindex", "0");
g.setAttribute("role", "listitem");
g.setAttribute("data-i", i);
var poly = document.createElementNS(ns, "polygon");
poly.setAttribute("points",
x1t + "," + y + " " + x2t + "," + y + " " +
x2b + "," + (y + rowH) + " " + x1b + "," + (y + rowH));
poly.setAttribute("fill", s.color);
poly.setAttribute("rx", "6");
g.appendChild(poly);
var lbl = document.createElementNS(ns, "text");
lbl.setAttribute("class", "funnel-label");
lbl.setAttribute("x", cx);
lbl.setAttribute("y", y + rowH / 2 - 3);
lbl.setAttribute("text-anchor", "middle");
lbl.textContent = s.label;
g.appendChild(lbl);
var valT = document.createElementNS(ns, "text");
valT.setAttribute("class", "funnel-val");
valT.setAttribute("x", cx);
valT.setAttribute("y", y + rowH / 2 + 14);
valT.setAttribute("text-anchor", "middle");
valT.textContent = fmtInt(v);
g.appendChild(valT);
if (i > 0) {
var convT = document.createElementNS(ns, "text");
convT.setAttribute("class", "funnel-conv");
convT.setAttribute("x", W - padX);
convT.setAttribute("y", y - 2);
convT.setAttribute("text-anchor", "end");
convT.textContent = "↳ " + conv.toFixed(1) + "% step";
funnelEl.appendChild(convT);
}
funnelEl.appendChild(g);
funnelData.push({ i: i, label: s.label, value: v, conv: conv, color: s.color,
ofTop: (v / max) * 100, cx: cx, cy: y + rowH / 2 });
}
bindFunnelHover();
renderLegend(data);
}
function bindFunnelHover() {
$$(".funnel-step", funnelEl).forEach(function (g) {
var i = +g.getAttribute("data-i");
var show = function (clientFromEvt) {
var d = funnelData[i];
funnelTip.hidden = false;
funnelTip.innerHTML =
"<strong>" + d.label + "</strong> · " + fmtInt(d.value) +
(i === 0 ? "" : " · " + d.conv.toFixed(1) + "% step") +
" · " + d.ofTop.toFixed(1) + "% of top";
// position over the step center, relative to funnel-wrap
var rect = funnelEl.getBoundingClientRect();
var scaleX = rect.width / 520, scaleY = rect.height / 280;
funnelTip.style.left = (d.cx * scaleX) + "px";
funnelTip.style.top = (d.cy * scaleY) + "px";
};
g.addEventListener("mouseenter", show);
g.addEventListener("focus", show);
g.addEventListener("mouseleave", function () { funnelTip.hidden = true; });
g.addEventListener("blur", function () { funnelTip.hidden = true; });
});
}
function renderLegend(data) {
var t = data.totals;
var vals = { Visitors: t.visitors, Leads: t.leads, Trials: t.trials, Paid: t.paid };
var legend = $("#funnelLegend");
legend.innerHTML = STAGES.map(function (s) {
return '<li class="fl-item">' +
'<span class="fl-dot" style="background:' + s.color + '"></span>' +
'<span class="fl-name">' + s.label + '</span>' +
'<span class="fl-num">' + fmtInt(vals[s.label]) + '</span></li>';
}).join("");
var chName = state.channel === "all" ? "All channels"
: (CHANNELS.filter(function (c) { return c.id === state.channel; })[0] || {}).name;
$("#funnelSub").textContent = chName + " · " + data.range.label;
}
/* ---------- CHANNEL bars + table ---------- */
function metricInfo(m) {
if (m === "spend") return { key: "spend", fmt: fmtK, label: "Spend", invert: false };
if (m === "cac") return { key: "cac", fmt: function (v) { return "$" + Math.round(v); }, label: "CAC", invert: true };
return { key: "roas", fmt: function (v) { return v.toFixed(2) + "×"; }, label: "ROAS", invert: false };
}
function renderChannels(data) {
var rows = data.rows.slice();
var info = metricInfo(state.chanMetric);
var max = Math.max.apply(null, rows.map(function (r) { return r[info.key]; })) || 1;
var bars = $("#bars");
bars.innerHTML = rows.map(function (r) {
return '<div class="bar-row">' +
'<span class="bar-name">' + r.name + '</span>' +
'<span class="bar-track"><span class="bar-fill" data-w="' +
((r[info.key] / max) * 100).toFixed(1) + '" style="background:' + r.color + '"></span></span>' +
'<span class="bar-val">' + info.fmt(r[info.key]) + '</span></div>';
}).join("");
// animate widths in
requestAnimationFrame(function () {
$$(".bar-fill", bars).forEach(function (f) { f.style.width = f.getAttribute("data-w") + "%"; });
});
var tbody = $("#chanRows");
// table sorted by spend desc for a stable reading order
var sorted = rows.slice().sort(function (a, b) { return b.spend - a.spend; });
tbody.innerHTML = sorted.map(function (r) {
var cls = r.roas >= 3 ? "good" : r.roas >= 2 ? "mid" : "low";
return '<tr>' +
'<td><span class="ch-name"><span class="ch-swatch" style="background:' + r.color + '"></span>' + r.name + '</span></td>' +
'<td class="num">' + fmtK(r.spend) + '</td>' +
'<td class="num">$' + Math.round(r.cac) + '</td>' +
'<td class="num"><span class="roas-pill ' + cls + '">' + r.roas.toFixed(2) + '×</span></td>' +
'</tr>';
}).join("");
}
/* ---------- CAMPAIGNS ---------- */
function renderCampaigns() {
var rangeMult = RANGE[state.range].mult;
var list = CAMPAIGNS.filter(function (c) {
return state.channel === "all" || c.channel === state.channel;
});
list = list.slice().sort(function (a, b) { return b.roas - a.roas; });
var labels = { live: "Live", paused: "Paused", review: "In review" };
var chName = function (id) {
var c = CHANNELS.filter(function (x) { return x.id === id; })[0];
return c ? c.name : id;
};
$("#campList").innerHTML = list.map(function (c) {
var spend = c.spend * rangeMult;
return '<li class="camp">' +
'<span class="camp-name"><strong>' + c.name + '</strong><small>' + chName(c.channel) + '</small></span>' +
'<span class="camp-metric"><span>Spend</span><strong>' + fmtK(spend) + '</strong></span>' +
'<span class="camp-metric"><span>CAC</span><strong>$' + c.cac + '</strong></span>' +
'<span class="camp-metric"><span>ROAS</span><strong>' + c.roas.toFixed(1) + '×</strong></span>' +
'<span class="status ' + c.status + '">' + labels[c.status] + '</span>' +
'</li>';
}).join("");
var running = list.filter(function (c) { return c.status === "live"; }).length;
$("#campSub").textContent = list.length + " campaigns · " + running + " running · sorted by ROAS";
}
/* ---------- full render ---------- */
var kpisEl = $("#kpis");
function renderAll(opts) {
var data = compute();
renderKpis(data);
renderFunnel(data);
renderChannels(data);
renderCampaigns();
if (opts && opts.busy) {
kpisEl.setAttribute("aria-busy", "true");
setTimeout(function () { kpisEl.setAttribute("aria-busy", "false"); }, 280);
}
}
/* ---------- interactions ---------- */
// date range
$$(".seg-btn").forEach(function (b) {
b.addEventListener("click", function () {
$$(".seg-btn").forEach(function (x) { x.classList.remove("is-on"); x.removeAttribute("aria-pressed"); });
b.classList.add("is-on");
b.setAttribute("aria-pressed", "true");
state.range = b.getAttribute("data-range");
renderAll({ busy: true });
toast("Range · " + RANGE[state.range].label);
});
});
// channel filter
$("#channelSelect").addEventListener("change", function () {
state.channel = this.value;
renderAll({ busy: true });
var n = this.options[this.selectedIndex].text;
toast("Filtered · " + n);
});
// channel metric tabs
$$(".w-tab").forEach(function (t) {
t.addEventListener("click", function () {
$$(".w-tab").forEach(function (x) { x.classList.remove("is-on"); x.setAttribute("aria-selected", "false"); });
t.classList.add("is-on");
t.setAttribute("aria-selected", "true");
state.chanMetric = t.getAttribute("data-metric");
var data = compute();
renderChannels(data);
});
});
// reset + export
$("#resetBtn").addEventListener("click", function () {
state.range = "30d"; state.channel = "all"; state.chanMetric = "roas";
$("#channelSelect").value = "all";
$$(".seg-btn").forEach(function (x) {
var on = x.getAttribute("data-range") === "30d";
x.classList.toggle("is-on", on);
if (on) x.setAttribute("aria-pressed", "true"); else x.removeAttribute("aria-pressed");
});
$$(".w-tab").forEach(function (x) {
var on = x.getAttribute("data-metric") === "roas";
x.classList.toggle("is-on", on);
x.setAttribute("aria-selected", on ? "true" : "false");
});
renderAll({ busy: true });
toast("Filters reset");
});
$("#exportBtn").addEventListener("click", function () {
toast("Export queued — report.csv");
});
$$(".menu-btn").forEach(function (b) {
b.addEventListener("click", function () { toast("Widget menu (demo)"); });
});
// off-canvas nav
var shell = $("#shell");
$("#sideOpen").addEventListener("click", function () { shell.classList.add("nav-open"); });
$("#sideClose").addEventListener("click", function () { shell.classList.remove("nav-open"); });
$$(".nav-item").forEach(function (a) {
a.addEventListener("click", function (e) {
e.preventDefault();
$$(".nav-item").forEach(function (x) { x.classList.remove("is-active"); x.removeAttribute("aria-current"); });
a.classList.add("is-active");
a.setAttribute("aria-current", "page");
shell.classList.remove("nav-open");
});
});
// reposition tooltip target on resize handled implicitly via getBoundingClientRect
/* ---------- live tick ---------- */
function startLive() {
stopLive();
liveTimer = setInterval(function () {
// gentle bounded random walk on volume
liveJitter = clamp(liveJitter + (Math.random() - 0.5) * 0.012, -0.05, 0.06);
var data = compute();
renderKpis(data);
// refresh funnel numbers without rebuilding hover bindings every tick
var t = data.totals;
var vals = [t.visitors, t.leads, t.trials, t.paid];
$$(".funnel-step .funnel-val", funnelEl).forEach(function (txt, i) {
txt.textContent = fmtInt(vals[i]);
if (funnelData[i]) funnelData[i].value = vals[i];
});
var legendNums = $$("#funnelLegend .fl-num");
legendNums.forEach(function (n, i) { n.textContent = fmtInt(vals[i]); });
}, 3000);
}
function stopLive() { if (liveTimer) clearInterval(liveTimer); }
document.addEventListener("visibilitychange", function () {
if (document.hidden) stopLive(); else startLive();
});
/* ---------- boot ---------- */
renderAll();
startLive();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lumenpath — Marketing Dashboard</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="shell" id="shell">
<!-- ============ SIDEBAR ============ -->
<aside class="sidebar" id="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◭</span>
<span class="brand-name">Lumenpath</span>
<button class="side-close" id="sideClose" aria-label="Close navigation">✕</button>
</div>
<nav class="nav" aria-label="Sections">
<p class="nav-label">Marketing</p>
<a class="nav-item is-active" href="#" aria-current="page"><span class="ni-ico" aria-hidden="true">◧</span> Overview</a>
<a class="nav-item" href="#"><span class="ni-ico" aria-hidden="true">⤳</span> Funnels</a>
<a class="nav-item" href="#"><span class="ni-ico" aria-hidden="true">◉</span> Channels</a>
<a class="nav-item" href="#"><span class="ni-ico" aria-hidden="true">▦</span> Campaigns</a>
<a class="nav-item" href="#"><span class="ni-ico" aria-hidden="true">◴</span> Attribution</a>
<p class="nav-label">Account</p>
<a class="nav-item" href="#"><span class="ni-ico" aria-hidden="true">⚙</span> Settings</a>
<a class="nav-item" href="#"><span class="ni-ico" aria-hidden="true">◔</span> Billing</a>
</nav>
<div class="side-card">
<p class="sc-title">Q3 budget</p>
<div class="sc-bar" role="img" aria-label="68% of quarterly budget spent">
<span style="width:68%"></span>
</div>
<p class="sc-meta"><strong>$204k</strong> of $300k spent</p>
</div>
<div class="side-user">
<span class="avatar" aria-hidden="true">MR</span>
<span class="su-meta">
<strong>Mara Reyes</strong>
<small>Growth lead</small>
</span>
</div>
</aside>
<!-- ============ MAIN ============ -->
<div class="content">
<!-- topbar -->
<header class="topbar">
<button class="icon-btn side-open" id="sideOpen" aria-label="Open navigation">☰</button>
<div class="page-head">
<h1>Marketing overview</h1>
<p class="crumb">Lumenpath · Demand generation</p>
</div>
<div class="top-actions">
<label class="search">
<span class="s-ico" aria-hidden="true">⌕</span>
<input type="search" placeholder="Search campaigns…" aria-label="Search campaigns" />
</label>
<button class="icon-btn" aria-label="Notifications">◔<span class="dot" aria-hidden="true"></span></button>
</div>
</header>
<main class="main" aria-label="Marketing dashboard">
<!-- ============ FILTER BAR ============ -->
<section class="filterbar" aria-label="Filters">
<div class="seg" role="group" aria-label="Date range">
<button class="seg-btn" data-range="7d">7d</button>
<button class="seg-btn is-on" data-range="30d" aria-pressed="true">30d</button>
<button class="seg-btn" data-range="90d">90d</button>
<button class="seg-btn" data-range="12m">12m</button>
</div>
<div class="f-spacer"></div>
<label class="select">
<span class="sel-label">Channel</span>
<select id="channelSelect" aria-label="Channel">
<option value="all">All channels</option>
<option value="organic">Organic</option>
<option value="paid">Paid search</option>
<option value="social">Social</option>
<option value="email">Email</option>
</select>
</label>
<button class="btn ghost" id="resetBtn">Reset</button>
<button class="btn primary" id="exportBtn">Export</button>
</section>
<!-- ============ KPI ROW ============ -->
<section class="kpis" id="kpis" aria-label="Key metrics" aria-busy="false">
<article class="kpi card" data-kpi="sessions">
<header class="kpi-head">
<span class="kpi-label">Sessions</span>
<button class="menu-btn" aria-label="Sessions options">⋯</button>
</header>
<div class="kpi-value" data-value>—</div>
<div class="kpi-foot">
<span class="delta" data-delta>—</span>
<svg class="spark" viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true" data-spark></svg>
</div>
</article>
<article class="kpi card" data-kpi="conv">
<header class="kpi-head">
<span class="kpi-label">Conversion rate</span>
<button class="menu-btn" aria-label="Conversion options">⋯</button>
</header>
<div class="kpi-value" data-value>—</div>
<div class="kpi-foot">
<span class="delta" data-delta>—</span>
<svg class="spark" viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true" data-spark></svg>
</div>
</article>
<article class="kpi card" data-kpi="cac">
<header class="kpi-head">
<span class="kpi-label">CAC</span>
<button class="menu-btn" aria-label="CAC options">⋯</button>
</header>
<div class="kpi-value" data-value>—</div>
<div class="kpi-foot">
<span class="delta" data-delta>—</span>
<svg class="spark" viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true" data-spark></svg>
</div>
</article>
<article class="kpi card" data-kpi="roas">
<header class="kpi-head">
<span class="kpi-label">Blended ROAS</span>
<button class="menu-btn" aria-label="ROAS options">⋯</button>
</header>
<div class="kpi-value" data-value>—</div>
<div class="kpi-foot">
<span class="delta" data-delta>—</span>
<svg class="spark" viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true" data-spark></svg>
</div>
</article>
</section>
<!-- ============ MAIN GRID ============ -->
<section class="grid">
<!-- FUNNEL -->
<article class="card widget funnel-card">
<header class="w-head">
<div>
<h2 class="w-title">Conversion funnel</h2>
<p class="w-sub" id="funnelSub">All channels · last 30 days</p>
</div>
<button class="menu-btn" aria-label="Funnel options">⋯</button>
</header>
<div class="funnel-wrap">
<svg class="funnel" id="funnel" viewBox="0 0 520 280" role="img"
aria-label="Conversion funnel from visitors to paid customers">
<!-- steps injected by JS -->
</svg>
<div class="funnel-tip" id="funnelTip" role="status" aria-live="polite" hidden></div>
</div>
<ol class="funnel-legend" id="funnelLegend" aria-label="Funnel steps"></ol>
</article>
<!-- CHANNELS -->
<article class="card widget channel-card">
<header class="w-head">
<div>
<h2 class="w-title">Channel performance</h2>
<p class="w-sub">Spend · CAC · ROAS</p>
</div>
<div class="w-tabs" role="tablist" aria-label="Channel metric">
<button class="w-tab is-on" role="tab" aria-selected="true" data-metric="roas">ROAS</button>
<button class="w-tab" role="tab" aria-selected="false" data-metric="spend">Spend</button>
<button class="w-tab" role="tab" aria-selected="false" data-metric="cac">CAC</button>
</div>
</header>
<div class="bars" id="bars" aria-hidden="true"></div>
<table class="chan-table">
<thead>
<tr>
<th scope="col">Channel</th>
<th scope="col" class="num">Spend</th>
<th scope="col" class="num">CAC</th>
<th scope="col" class="num">ROAS</th>
</tr>
</thead>
<tbody id="chanRows"></tbody>
</table>
</article>
<!-- CAMPAIGNS -->
<article class="card widget campaign-card">
<header class="w-head">
<div>
<h2 class="w-title">Active campaigns</h2>
<p class="w-sub" id="campSub">6 running · sorted by ROAS</p>
</div>
<button class="menu-btn" aria-label="Campaign options">⋯</button>
</header>
<ul class="camp-list" id="campList" aria-label="Campaign list"></ul>
</article>
</section>
</main>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="script.js"></script>
</body>
</html>Marketing (funnels · channels)
A production-grade marketing dashboard for the fictional Lumenpath growth team. A sticky left sidebar carries the section nav, a quarterly budget meter and the signed-in user; the topbar holds the page title, a search field and a notification bell. A filter bar combines a date-range segmented control (7d / 30d / 90d / 12m) with a channel selector plus Reset and Export actions. Everything uses landmark roles, aria-pressed/aria-selected/aria-busy, focus-visible states and WCAG AA contrast.
The body opens with a four-up KPI row — Sessions, Conversion rate, CAC and Blended ROAS — each showing a value, an up/down delta coloured by whether the move is good (lower CAC counts as a win), and a tiny inline-SVG sparkline. Below it, an SVG conversion funnel renders Visitors → Leads → Trials → Paid as tapered segments with per-step conversion percentages and follow-the-step hover tooltips, beside a channel-performance widget that pairs an animated bar chart (toggle ROAS / Spend / CAC) with a spend·CAC·ROAS table whose ROAS pills shift colour by tier. A full-width campaign list sorts by ROAS and tags each row Live, Paused or In review.
All numbers come from a deterministic synthetic dataset, so each filter combination is stable across redraws. Changing the date range or channel recomputes every KPI, the funnel, the channel widget and the campaign list behind a short loading shimmer; the metric tabs re-plot just the channel bars. A bounded live tick nudges the volume figures every few seconds and pauses when the tab is hidden. The layout reflows from four to two to one column and the sidebar becomes an off-canvas drawer below 720px, scaling cleanly to 360px.
Illustrative UI only — Lumenpath is fictional and all figures are synthetic.