Dashboard — List + detail (master-detail)
A polished master-detail dashboard for a fictional SaaS CRM: a scrollable left list of customers with avatars, status badges and mini metrics, beside a rich right pane showing the selected account header, four KPI tiles with deltas and inline-SVG sparklines, an animated bar chart, a spend donut, and an activity timeline. Clicking or arrow-keying a row selects it; live search and status filters narrow the list; on mobile the detail slides in full-screen with a back button. Vanilla JS, no libraries.
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,
body {
margin: 0;
height: 100%;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button {
font: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- App shell ---------- */
.app {
display: grid;
grid-template-columns: 68px minmax(280px, 360px) 1fr;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
/* ---------- Rail ---------- */
.rail {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 0;
background: var(--ink);
color: rgba(255, 255, 255, 0.7);
}
.rail__brand {
margin-bottom: 12px;
}
.rail__logo {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--brand), var(--accent));
color: #fff;
font-weight: 800;
font-size: 1.1rem;
box-shadow: var(--sh-2);
}
.rail__nav {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.rail__link {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 12px;
color: inherit;
transition: background 0.15s, color 0.15s;
}
.rail__link:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.rail__link.is-active {
background: var(--brand);
color: #fff;
}
.rail__me {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-weight: 700;
font-size: 0.78rem;
}
/* ---------- Master list ---------- */
.list {
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--line);
min-height: 0;
}
.list__head {
padding: 18px 18px 14px;
border-bottom: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 12px;
}
.list__titlebar {
display: flex;
align-items: center;
gap: 10px;
}
.list__title {
margin: 0;
font-size: 1.18rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.list__count {
display: inline-grid;
place-items: center;
min-width: 24px;
height: 22px;
padding: 0 7px;
border-radius: 999px;
background: var(--brand-50);
color: var(--brand-700);
font-size: 0.72rem;
font-weight: 700;
}
.search {
position: relative;
display: flex;
align-items: center;
}
.search svg {
position: absolute;
left: 11px;
color: var(--muted);
pointer-events: none;
}
.search input {
width: 100%;
padding: 9px 12px 9px 34px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--bg);
font: inherit;
font-size: 0.86rem;
color: var(--ink);
}
.search input:focus {
outline: none;
border-color: var(--brand);
background: var(--white);
box-shadow: 0 0 0 3px var(--brand-50);
}
.seg {
display: flex;
gap: 4px;
background: var(--bg);
padding: 4px;
border-radius: var(--r-sm);
}
.seg__btn {
flex: 1;
border: 0;
background: transparent;
padding: 6px 4px;
border-radius: 6px;
font-size: 0.76rem;
font-weight: 600;
color: var(--muted);
transition: background 0.15s, color 0.15s;
}
.seg__btn:hover {
color: var(--ink-2);
}
.seg__btn.is-on {
background: var(--white);
color: var(--ink);
box-shadow: var(--sh-1);
}
.rows {
list-style: none;
margin: 0;
padding: 6px;
overflow-y: auto;
flex: 1;
min-height: 0;
scrollbar-width: thin;
}
.rows:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--brand-50);
}
.row {
display: grid;
grid-template-columns: 38px 1fr auto;
gap: 11px;
align-items: center;
padding: 10px 10px;
border-radius: var(--r-sm);
cursor: pointer;
border: 1px solid transparent;
transition: background 0.12s, border-color 0.12s;
}
.row:hover {
background: var(--bg);
}
.row.is-current {
background: var(--brand-50);
border-color: rgba(91, 91, 240, 0.28);
}
.row__avatar {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 50%;
font-weight: 700;
font-size: 0.8rem;
color: #fff;
}
.avatar[data-tone="brand"],
.row__avatar[data-tone="brand"] {
background: linear-gradient(135deg, var(--brand), var(--brand-d));
}
.avatar[data-tone="accent"],
.row__avatar[data-tone="accent"] {
background: linear-gradient(135deg, var(--accent), #06978b);
}
.avatar[data-tone="warn"],
.row__avatar[data-tone="warn"] {
background: linear-gradient(135deg, var(--warn), #b9701f);
}
.avatar[data-tone="ink"],
.row__avatar[data-tone="ink"] {
background: linear-gradient(135deg, #4b5170, var(--ink));
}
.row__main {
min-width: 0;
}
.row__name {
font-size: 0.88rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row__meta {
display: flex;
align-items: center;
gap: 7px;
margin-top: 2px;
}
.row__sub {
font-size: 0.74rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row__metric {
text-align: right;
}
.row__mval {
font-size: 0.84rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.row__mdelta {
font-size: 0.68rem;
font-weight: 600;
}
.is-up {
color: var(--ok);
}
.is-down {
color: var(--danger);
}
/* ---------- Badges ---------- */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.01em;
white-space: nowrap;
}
.badge::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.badge[data-status="active"] {
background: #e4f5ec;
color: #1f7a52;
}
.badge[data-status="trial"] {
background: var(--brand-50);
color: var(--brand-700);
}
.badge[data-status="churned"] {
background: #fbe9e6;
color: #ad3b2c;
}
.rows__empty {
margin: 28px 18px;
text-align: center;
color: var(--muted);
font-size: 0.86rem;
}
/* ---------- Detail pane ---------- */
.detail {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.detail__head {
padding: 18px 24px 16px;
border-bottom: 1px solid var(--line);
background: var(--surface);
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"back back"
"id actions";
gap: 12px 16px;
align-items: center;
}
.back {
grid-area: back;
display: none;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: var(--brand-700);
font-weight: 600;
font-size: 0.86rem;
padding: 4px 0;
width: max-content;
}
.detail__id {
grid-area: id;
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 14px;
font-weight: 800;
font-size: 1rem;
color: #fff;
flex-shrink: 0;
}
.detail__name {
margin: 0;
font-size: 1.24rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.detail__sub {
margin: 1px 0 0;
font-size: 0.8rem;
color: var(--muted);
}
.detail__id .badge {
align-self: center;
}
.detail__actions {
grid-area: actions;
display: flex;
gap: 8px;
}
.btn {
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 8px 14px;
font-size: 0.84rem;
font-weight: 600;
transition: background 0.15s, border-color 0.15s, transform 0.05s;
}
.btn:active {
transform: translateY(1px);
}
.btn--primary {
background: var(--brand);
color: #fff;
}
.btn--primary:hover {
background: var(--brand-d);
}
.btn--ghost {
background: var(--white);
border-color: var(--line-2);
color: var(--ink-2);
}
.btn--ghost:hover {
background: var(--bg);
}
.btn--sm {
padding: 5px 10px;
font-size: 0.76rem;
}
.detail__body {
padding: 20px 24px 28px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
/* ---------- KPI tiles ---------- */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.kpi {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 16px;
box-shadow: var(--sh-1);
}
.kpi__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.kpi__label {
font-size: 0.74rem;
font-weight: 600;
color: var(--muted);
}
.kpi__menu {
border: 0;
background: transparent;
color: var(--muted);
font-size: 1rem;
line-height: 1;
padding: 0 2px;
border-radius: 6px;
}
.kpi__menu:hover {
color: var(--ink);
background: var(--bg);
}
.kpi__val {
display: block;
font-size: 1.4rem;
font-weight: 800;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.kpi__delta {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
margin-top: 2px;
}
.kpi .spark {
display: block;
width: 100%;
height: 30px;
margin-top: 8px;
}
/* ---------- Grid + cards ---------- */
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 14px;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-1);
padding: 16px 18px 18px;
min-width: 0;
}
.card--wide {
grid-column: 1 / -1;
}
.card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
}
.card__title {
margin: 0;
font-size: 0.98rem;
font-weight: 700;
}
.card__tabs {
display: flex;
gap: 6px;
}
.chip {
border: 1px solid var(--line-2);
background: var(--white);
color: var(--muted);
padding: 4px 10px;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 600;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.chip:hover {
color: var(--ink-2);
}
.chip.is-on {
background: var(--brand-50);
border-color: rgba(91, 91, 240, 0.4);
color: var(--brand-700);
}
/* ---------- Bar chart ---------- */
.chartwrap {
width: 100%;
}
.chart {
width: 100%;
height: auto;
display: block;
}
.chart__grid line {
stroke: var(--line);
stroke-width: 1;
}
.bar {
fill: var(--brand);
transition: height 0.5s cubic-bezier(0.2, 0.8, 0.2, 1), y 0.5s cubic-bezier(0.2, 0.8, 0.2, 1), fill 0.2s;
rx: 3;
}
.bar:hover {
fill: var(--brand-d);
}
.chart__xlabels text {
font-size: 11px;
fill: var(--muted);
text-anchor: middle;
}
.chart__yval {
font-size: 10px;
fill: var(--muted);
}
/* ---------- Donut ---------- */
.donutwrap {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.donut {
width: 130px;
height: 130px;
flex-shrink: 0;
}
.donut__track {
stroke: var(--bg);
}
.donut circle:not(.donut__track) {
transition: stroke-dashoffset 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.donut__big {
font-size: 17px;
font-weight: 800;
fill: var(--ink);
text-anchor: middle;
}
.donut__sub {
font-size: 10px;
fill: var(--muted);
text-anchor: middle;
}
.legend {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 120px;
}
.legend li {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
color: var(--ink-2);
}
.legend b {
margin-left: auto;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.dot {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
/* ---------- Timeline ---------- */
.timeline {
list-style: none;
margin: 0;
padding: 0;
position: relative;
}
.timeline::before {
content: "";
position: absolute;
left: 7px;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--line);
}
.tl {
position: relative;
padding: 0 0 16px 28px;
}
.tl:last-child {
padding-bottom: 0;
}
.tl::before {
content: "";
position: absolute;
left: 2px;
top: 4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--white);
border: 2px solid var(--brand);
}
.tl[data-kind="ok"]::before {
border-color: var(--ok);
}
.tl[data-kind="warn"]::before {
border-color: var(--warn);
}
.tl[data-kind="danger"]::before {
border-color: var(--danger);
}
.tl__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.tl__title {
font-size: 0.86rem;
font-weight: 600;
}
.tl__time {
font-size: 0.72rem;
color: var(--muted);
white-space: nowrap;
}
.tl__desc {
font-size: 0.8rem;
color: var(--muted);
margin-top: 1px;
}
.tl--new {
animation: pop 0.4s ease;
}
@keyframes pop {
from {
opacity: 0;
transform: translateY(-6px);
}
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 16px);
background: var(--ink);
color: #fff;
padding: 10px 18px;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 50;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 920px) {
.app {
grid-template-columns: 60px minmax(240px, 300px) 1fr;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
.rail {
display: none;
}
.list {
border-right: 0;
}
/* master/detail swap on mobile */
body[data-view="master"] .detail {
display: none;
}
body[data-view="detail"] .list {
display: none;
}
body[data-view="detail"] .detail {
display: flex;
}
.back {
display: inline-flex;
}
.detail__head {
grid-template-areas:
"back back"
"id id"
"actions actions";
}
.detail__actions {
justify-content: stretch;
}
.detail__actions .btn {
flex: 1;
}
}
@media (max-width: 400px) {
.kpis {
grid-template-columns: 1fr;
}
.detail__body,
.detail__head {
padding-left: 16px;
padding-right: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition-duration: 0.001ms !important;
animation-duration: 0.001ms !important;
}
}/* Master-detail dashboard — Nimbusly (fictional SaaS CRM)
Vanilla JS only. No libraries. */
(function () {
"use strict";
/* ---------- Fictional data ---------- */
var CUSTOMERS = [
{
id: "acme", name: "Acme Atlas Co.", initials: "AC", tone: "brand",
status: "active", plan: "Enterprise", place: "Seattle, US", since: "2023",
metric: "$12.4k", mdelta: "+8.4%", up: true,
mrr: ["$12,480", "▲ 8.4%", true], seats: ["184 / 220", "▲ 12 mo", true],
health: ["92", "▼ 3 pts", false], tickets: 3,
api: [320, 410, 380, 520, 610, 580, 690, 720, 660, 810, 870, 920],
storage: [120, 140, 160, 150, 190, 210, 230, 250, 280, 300, 330, 360],
spend: [52, 31, 17], spendTotal: "$18.6k",
timeline: [
{ k: "ok", t: "Plan upgraded to Enterprise", d: "Annual contract, 220 seats", time: "2h ago" },
{ k: "", t: "New integration connected", d: "Slack workspace linked by M. Reyes", time: "Yesterday" },
{ k: "warn", t: "Usage hit 80% of API quota", d: "Auto-alert sent to account owner", time: "3 days ago" },
{ k: "", t: "Quarterly business review", d: "NPS recorded at 64", time: "Last week" }
]
},
{
id: "bright", name: "Brightwave Labs", initials: "BL", tone: "accent",
status: "active", plan: "Growth", place: "Berlin, DE", since: "2022",
metric: "$6.9k", mdelta: "+3.1%", up: true,
mrr: ["$6,920", "▲ 3.1%", true], seats: ["88 / 100", "▲ 6 mo", true],
health: ["87", "▲ 2 pts", true], tickets: 1,
api: [210, 260, 240, 300, 340, 360, 390, 420, 400, 460, 480, 510],
storage: [80, 90, 110, 130, 140, 150, 170, 180, 200, 210, 230, 240],
spend: [44, 38, 18], spendTotal: "$9.4k",
timeline: [
{ k: "ok", t: "Renewal confirmed", d: "12-month term signed", time: "1d ago" },
{ k: "", t: "Seat count increased", d: "+18 seats added by admin", time: "4 days ago" },
{ k: "", t: "Webhook endpoint added", d: "events.brightwave.io", time: "Last week" }
]
},
{
id: "corevia", name: "Corevia Health", initials: "CH", tone: "ink",
status: "trial", plan: "Trial", place: "Toronto, CA", since: "2026",
metric: "$0", mdelta: "14d left", up: true,
mrr: ["$0", "▲ trial", true], seats: ["12 / 25", "▲ new", true],
health: ["71", "▲ 9 pts", true], tickets: 0,
api: [40, 60, 90, 120, 150, 180, 210, 240, 280, 310, 340, 380],
storage: [10, 20, 30, 45, 60, 75, 90, 110, 130, 150, 170, 190],
spend: [70, 20, 10], spendTotal: "$0",
timeline: [
{ k: "", t: "Trial started", d: "14-day Growth trial activated", time: "5h ago" },
{ k: "ok", t: "First workflow created", d: "Onboarding milestone reached", time: "5h ago" },
{ k: "", t: "Demo call booked", d: "Scheduled with sales engineer", time: "Yesterday" }
]
},
{
id: "delta", name: "Delta Forge Inc.", initials: "DF", tone: "brand",
status: "active", plan: "Enterprise", place: "Austin, US", since: "2021",
metric: "$21.0k", mdelta: "+11.2%", up: true,
mrr: ["$21,040", "▲ 11.2%", true], seats: ["410 / 500", "▲ 40 mo", true],
health: ["95", "▲ 1 pt", true], tickets: 5,
api: [560, 640, 700, 760, 820, 880, 910, 970, 1020, 1080, 1140, 1210],
storage: [300, 340, 380, 420, 460, 500, 540, 580, 620, 660, 700, 740],
spend: [58, 26, 16], spendTotal: "$31.2k",
timeline: [
{ k: "warn", t: "5 open support tickets", d: "SLA breach risk on 1 ticket", time: "1h ago" },
{ k: "ok", t: "Expansion deal closed", d: "+90 seats, +$4.2k MRR", time: "2 days ago" },
{ k: "", t: "SSO configured", d: "Okta SAML enabled org-wide", time: "Last week" }
]
},
{
id: "ember", name: "Ember Studio", initials: "ES", tone: "warn",
status: "churned", plan: "Growth", place: "Lisbon, PT", since: "2023",
metric: "$2.1k", mdelta: "-22%", up: false,
mrr: ["$2,140", "▼ 22%", false], seats: ["9 / 40", "▼ 11 mo", false],
health: ["38", "▼ 18 pts", false], tickets: 4,
api: [180, 170, 150, 130, 120, 100, 90, 80, 70, 60, 50, 40],
storage: [120, 115, 110, 100, 95, 90, 80, 70, 65, 55, 50, 45],
spend: [40, 25, 35], spendTotal: "$3.0k",
timeline: [
{ k: "danger", t: "Health score dropped below 40", d: "Flagged for churn outreach", time: "30m ago" },
{ k: "warn", t: "Seats reduced", d: "-22 seats removed by admin", time: "1 week ago" },
{ k: "", t: "Downgrade requested", d: "Support ticket #4821 opened", time: "2 weeks ago" }
]
},
{
id: "fjord", name: "Fjord Analytics", initials: "FA", tone: "accent",
status: "active", plan: "Growth", place: "Oslo, NO", since: "2024",
metric: "$5.4k", mdelta: "+5.6%", up: true,
mrr: ["$5,420", "▲ 5.6%", true], seats: ["64 / 80", "▲ 8 mo", true],
health: ["83", "▲ 4 pts", true], tickets: 2,
api: [150, 180, 210, 230, 260, 290, 320, 350, 380, 410, 440, 470],
storage: [60, 70, 85, 95, 110, 125, 140, 155, 170, 185, 200, 215],
spend: [50, 34, 16], spendTotal: "$7.1k",
timeline: [
{ k: "ok", t: "Activated advanced reports", d: "Add-on enabled by data team", time: "3h ago" },
{ k: "", t: "Invited 6 teammates", d: "Pending acceptance", time: "Yesterday" }
]
},
{
id: "grove", name: "Grovewright", initials: "GW", tone: "ink",
status: "trial", plan: "Trial", place: "Dublin, IE", since: "2026",
metric: "$0", mdelta: "6d left", up: false,
mrr: ["$0", "▲ trial", true], seats: ["4 / 25", "▲ new", true],
health: ["59", "▼ 5 pts", false], tickets: 1,
api: [20, 35, 50, 60, 70, 65, 80, 90, 85, 100, 110, 120],
storage: [5, 10, 18, 22, 30, 35, 42, 48, 55, 62, 70, 78],
spend: [60, 30, 10], spendTotal: "$0",
timeline: [
{ k: "warn", t: "Low trial engagement", d: "No workflows created in 3 days", time: "4h ago" },
{ k: "", t: "Trial started", d: "14-day Growth trial activated", time: "8 days ago" }
]
},
{
id: "helio", name: "Helio Robotics", initials: "HR", tone: "brand",
status: "active", plan: "Enterprise", place: "Tokyo, JP", since: "2020",
metric: "$18.8k", mdelta: "+6.9%", up: true,
mrr: ["$18,820", "▲ 6.9%", true], seats: ["360 / 420", "▲ 24 mo", true],
health: ["90", "▲ 2 pts", true], tickets: 2,
api: [480, 540, 600, 650, 710, 760, 820, 870, 930, 980, 1040, 1100],
storage: [260, 290, 320, 350, 380, 410, 440, 470, 500, 530, 560, 590],
spend: [55, 29, 16], spendTotal: "$27.8k",
timeline: [
{ k: "ok", t: "Multi-region deploy enabled", d: "APAC data residency configured", time: "5h ago" },
{ k: "", t: "API key rotated", d: "Security policy compliance", time: "2 days ago" },
{ k: "", t: "Custom dashboard shared", d: "12 viewers added", time: "Last week" }
]
}
];
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var rowsEl = $("#rows");
var emptyEl = $("#emptyState");
var countEl = $("#listCount");
var searchEl = $("#search");
var body = document.body;
var state = { filter: "all", query: "", selected: CUSTOMERS[0].id, metric: "api" };
/* ---------- Toast ---------- */
var toastTimer;
function toast(msg) {
var el = $("#toast");
el.textContent = msg;
el.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.classList.remove("is-show"); }, 2200);
}
/* ---------- List rendering ---------- */
function visibleCustomers() {
var q = state.query.trim().toLowerCase();
return CUSTOMERS.filter(function (c) {
if (state.filter !== "all" && c.status !== state.filter) return false;
if (q && (c.name + " " + c.place + " " + c.plan).toLowerCase().indexOf(q) === -1) return false;
return true;
});
}
var STATUS_LABEL = { active: "Active", trial: "Trial", churned: "At risk" };
function renderList() {
var list = visibleCustomers();
countEl.textContent = String(list.length);
rowsEl.innerHTML = "";
if (!list.length) {
emptyEl.hidden = false;
return;
}
emptyEl.hidden = true;
list.forEach(function (c) {
var li = document.createElement("li");
li.className = "row" + (c.id === state.selected ? " is-current" : "");
li.id = "row-" + c.id;
li.setAttribute("role", "option");
li.setAttribute("data-id", c.id);
li.setAttribute("aria-selected", c.id === state.selected ? "true" : "false");
li.innerHTML =
'<span class="row__avatar" data-tone="' + c.tone + '" aria-hidden="true">' + c.initials + "</span>" +
'<div class="row__main">' +
'<div class="row__name">' + c.name + "</div>" +
'<div class="row__meta">' +
'<span class="badge" data-status="' + c.status + '">' + STATUS_LABEL[c.status] + "</span>" +
'<span class="row__sub">' + c.plan + "</span>" +
"</div></div>" +
'<div class="row__metric">' +
'<div class="row__mval">' + c.metric + "</div>" +
'<div class="row__mdelta ' + (c.up ? "is-up" : "is-down") + '">' +
(c.up ? "▲ " : "▼ ") + c.mdelta + "</div></div>";
rowsEl.appendChild(li);
});
// keep aria-activedescendant valid
if (list.some(function (c) { return c.id === state.selected; })) {
rowsEl.setAttribute("aria-activedescendant", "row-" + state.selected);
} else {
rowsEl.setAttribute("aria-activedescendant", "");
}
}
/* ---------- Sparklines ---------- */
function sparkPoints(data) {
var w = 120, h = 32, pad = 2;
var min = Math.min.apply(null, data), max = Math.max.apply(null, data);
var span = max - min || 1;
return data.map(function (v, i) {
var x = pad + (i / (data.length - 1)) * (w - pad * 2);
var y = h - pad - ((v - min) / span) * (h - pad * 2);
return x.toFixed(1) + "," + y.toFixed(1);
}).join(" ");
}
/* ---------- Bar chart ---------- */
var MONTHS = ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun"];
function renderBars(c) {
var data = c[state.metric];
var grid = $("#usageGrid");
var bars = $("#usageBars");
var labels = $("#usageLabels");
var W = 520, H = 200, padL = 8, padR = 8, padT = 12, padB = 26;
var plotW = W - padL - padR, plotH = H - padT - padB;
var max = Math.max.apply(null, data) * 1.1;
grid.innerHTML = "";
bars.innerHTML = "";
labels.innerHTML = "";
// gridlines (4)
for (var g = 0; g <= 4; g++) {
var gy = padT + (g / 4) * plotH;
grid.innerHTML +=
'<line x1="' + padL + '" y1="' + gy.toFixed(1) + '" x2="' + (W - padR) + '" y2="' + gy.toFixed(1) + '"/>';
var val = Math.round((max * (4 - g)) / 4);
grid.innerHTML +=
'<text class="chart__yval" x="' + padL + '" y="' + (gy - 3).toFixed(1) + '">' + val + "</text>";
}
var n = data.length;
var slot = plotW / n;
var bw = slot * 0.56;
data.forEach(function (v, i) {
var bh = (v / max) * plotH;
var x = padL + i * slot + (slot - bw) / 2;
var y = padT + plotH - bh;
var r = document.createElementNS("http://www.w3.org/2000/svg", "rect");
r.setAttribute("class", "bar");
r.setAttribute("x", x.toFixed(1));
r.setAttribute("width", bw.toFixed(1));
r.setAttribute("y", (padT + plotH).toFixed(1));
r.setAttribute("height", "0");
r.setAttribute("rx", "3");
var lbl = (state.metric === "api" ? v + " calls" : v + " GB") + " · " + MONTHS[i];
r.innerHTML = "<title>" + lbl + "</title>";
bars.appendChild(r);
// animate in next frame
(function (rect, ty, th) {
requestAnimationFrame(function () {
rect.setAttribute("y", ty.toFixed(1));
rect.setAttribute("height", th.toFixed(1));
});
})(r, y, bh);
labels.innerHTML +=
'<text x="' + (padL + i * slot + slot / 2).toFixed(1) + '" y="' + (H - 8) + '">' + MONTHS[i] + "</text>";
});
}
/* ---------- Donut ---------- */
function renderDonut(c) {
var segs = [$("#dSeg1"), $("#dSeg2"), $("#dSeg3")];
var r = 48, circ = 2 * Math.PI * r;
var offset = 0;
var total = c.spend.reduce(function (a, b) { return a + b; }, 0) || 1;
c.spend.forEach(function (pct, i) {
var seg = segs[i];
var len = (pct / total) * circ;
seg.setAttribute("stroke-dasharray", len.toFixed(2) + " " + (circ - len).toFixed(2));
seg.setAttribute("stroke-dashoffset", (-offset).toFixed(2));
offset += len;
});
$("#donutTotal").textContent = c.spendTotal;
}
/* ---------- Timeline ---------- */
function renderTimeline(c, animateFirst) {
var ol = $("#timeline");
ol.innerHTML = "";
c.timeline.forEach(function (e, i) {
var li = document.createElement("li");
li.className = "tl" + (animateFirst && i === 0 ? " tl--new" : "");
if (e.k) li.setAttribute("data-kind", e.k);
li.innerHTML =
'<div class="tl__top"><span class="tl__title">' + e.t + "</span>" +
'<time class="tl__time">' + e.time + "</time></div>" +
'<div class="tl__desc">' + e.d + "</div>";
ol.appendChild(li);
});
}
/* ---------- Detail rendering ---------- */
function renderDetail(c, animate) {
$("#dAvatar").textContent = c.initials;
$("#dAvatar").setAttribute("data-tone", c.tone);
$("#dName").textContent = c.name;
$("#dSub").textContent = c.plan + " · " + c.place + " · since " + c.since;
var badge = $("#dBadge");
badge.textContent = STATUS_LABEL[c.status];
badge.setAttribute("data-status", c.status);
setKpi("kMrr", "kMrrD", "sparkMrr", c.mrr, c.api);
setKpi("kSeats", "kSeatsD", "sparkSeats", c.seats, c.storage);
setKpi("kHealth", "kHealthD", "sparkHealth", c.health, c.api.map(function (v) { return v % 100; }));
var tk = ["" + c.tickets, "▲ live", c.tickets > 0];
setKpi("kTickets", "kTicketsD", "sparkTickets", tk, [2, 3, 1, 4, 2, 3, c.tickets]);
renderBars(c);
renderDonut(c);
renderTimeline(c, animate);
}
function setKpi(valId, deltaId, sparkId, arr, sparkData) {
$("#" + valId).textContent = arr[0];
var d = $("#" + deltaId);
d.textContent = arr[1];
d.className = "kpi__delta " + (arr[2] ? "is-up" : "is-down");
$("#" + sparkId).setAttribute("points", sparkPoints(sparkData));
}
/* ---------- Selection ---------- */
function select(id, opts) {
opts = opts || {};
var c = CUSTOMERS.filter(function (x) { return x.id === id; })[0];
if (!c) return;
state.selected = id;
$$(".row").forEach(function (r) {
var on = r.getAttribute("data-id") === id;
r.classList.toggle("is-current", on);
r.setAttribute("aria-selected", on ? "true" : "false");
});
rowsEl.setAttribute("aria-activedescendant", "row-" + id);
renderDetail(c, true);
if (opts.scroll) {
var el = $("#row-" + id);
if (el) el.scrollIntoView({ block: "nearest" });
}
// mobile: push detail view
if (opts.push && window.matchMedia("(max-width: 720px)").matches) {
body.setAttribute("data-view", "detail");
}
}
/* ---------- Keyboard nav on the listbox ---------- */
function moveSelection(dir) {
var list = visibleCustomers();
if (!list.length) return;
var idx = list.findIndex(function (c) { return c.id === state.selected; });
if (idx === -1) idx = dir > 0 ? -1 : list.length;
var next = idx + dir;
if (next < 0) next = 0;
if (next > list.length - 1) next = list.length - 1;
select(list[next].id, { scroll: true });
}
/* ---------- Events ---------- */
rowsEl.addEventListener("click", function (e) {
var row = e.target.closest(".row");
if (row) select(row.getAttribute("data-id"), { push: true });
});
rowsEl.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); moveSelection(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); moveSelection(-1); }
else if (e.key === "Home") { e.preventDefault(); var l = visibleCustomers(); if (l[0]) select(l[0].id, { scroll: true }); }
else if (e.key === "End") { e.preventDefault(); var l2 = visibleCustomers(); if (l2.length) select(l2[l2.length - 1].id, { scroll: true }); }
else if (e.key === "Enter") { e.preventDefault(); select(state.selected, { push: true }); }
});
searchEl.addEventListener("input", function () {
state.query = searchEl.value;
renderList();
// if current selection filtered out, select first visible
var list = visibleCustomers();
if (list.length && !list.some(function (c) { return c.id === state.selected; })) {
select(list[0].id);
}
});
$$(".seg__btn").forEach(function (btn) {
btn.addEventListener("click", function () {
$$(".seg__btn").forEach(function (b) {
b.classList.remove("is-on");
b.setAttribute("aria-selected", "false");
});
btn.classList.add("is-on");
btn.setAttribute("aria-selected", "true");
state.filter = btn.getAttribute("data-filter");
renderList();
var list = visibleCustomers();
if (list.length && !list.some(function (c) { return c.id === state.selected; })) {
select(list[0].id);
}
});
});
$$(".chip[data-metric]").forEach(function (chip) {
chip.addEventListener("click", function () {
$$(".chip[data-metric]").forEach(function (b) {
b.classList.remove("is-on");
b.setAttribute("aria-selected", "false");
});
chip.classList.add("is-on");
chip.setAttribute("aria-selected", "true");
state.metric = chip.getAttribute("data-metric");
var c = CUSTOMERS.filter(function (x) { return x.id === state.selected; })[0];
if (c) renderBars(c);
});
});
$("#backBtn").addEventListener("click", function () {
body.setAttribute("data-view", "master");
var el = $("#row-" + state.selected);
if (el) el.scrollIntoView({ block: "nearest" });
});
$("#msgBtn").addEventListener("click", function () {
var c = CUSTOMERS.filter(function (x) { return x.id === state.selected; })[0];
toast("Message drafted to " + c.name);
});
$("#renewBtn").addEventListener("click", function () {
var c = CUSTOMERS.filter(function (x) { return x.id === state.selected; })[0];
toast("Renewal flow opened for " + c.name);
});
$("#logBtn").addEventListener("click", function () {
var c = CUSTOMERS.filter(function (x) { return x.id === state.selected; })[0];
c.timeline.unshift({ k: "ok", t: "Note added", d: "Logged by you · just now", time: "Just now" });
renderTimeline(c, true);
toast("Note added to timeline");
});
// KPI menus + non-functional decorative menus
document.addEventListener("click", function (e) {
if (e.target.classList && e.target.classList.contains("kpi__menu")) {
toast("Widget options");
}
});
/* ---------- Live ticking: open tickets fluctuate ---------- */
setInterval(function () {
var c = CUSTOMERS.filter(function (x) { return x.id === state.selected; })[0];
if (!c) return;
// small random walk, clamped 0..6
var delta = Math.random() < 0.5 ? -1 : 1;
if (Math.random() < 0.4) {
c.tickets = Math.max(0, Math.min(6, c.tickets + delta));
$("#kTickets").textContent = String(c.tickets);
var d = $("#kTicketsD");
d.textContent = c.tickets > 0 ? "▲ live" : "✓ clear";
d.className = "kpi__delta " + (c.tickets > 0 ? "is-up" : "is-down");
}
}, 4000);
/* ---------- Init ---------- */
renderList();
select(state.selected);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Master-Detail Dashboard — Nimbusly</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 data-view="master">
<div class="app">
<!-- Sidebar nav -->
<nav class="rail" aria-label="Primary">
<div class="rail__brand" aria-hidden="true">
<span class="rail__logo">N</span>
</div>
<ul class="rail__nav">
<li><a href="#" class="rail__link is-active" aria-current="page" title="Customers">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-6 8-6s8 2 8 6"/></svg>
</a></li>
<li><a href="#" class="rail__link" title="Analytics">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
</a></li>
<li><a href="#" class="rail__link" title="Billing">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg>
</a></li>
<li><a href="#" class="rail__link" title="Settings">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/></svg>
</a></li>
</ul>
<div class="rail__me" title="Mara Okonkwo" aria-hidden="true">MO</div>
</nav>
<!-- Master list -->
<section class="list" aria-label="Customer list" id="master">
<header class="list__head">
<div class="list__titlebar">
<h1 class="list__title">Customers</h1>
<span class="list__count" id="listCount">8</span>
</div>
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input type="search" id="search" placeholder="Search customers" aria-label="Search customers" autocomplete="off" />
</div>
<div class="seg" role="tablist" aria-label="Filter by status">
<button class="seg__btn is-on" role="tab" aria-selected="true" data-filter="all">All</button>
<button class="seg__btn" role="tab" aria-selected="false" data-filter="active">Active</button>
<button class="seg__btn" role="tab" aria-selected="false" data-filter="trial">Trial</button>
<button class="seg__btn" role="tab" aria-selected="false" data-filter="churned">At risk</button>
</div>
</header>
<ul class="rows" id="rows" role="listbox" aria-label="Customers" tabindex="0" aria-activedescendant="">
<!-- rows injected by script.js -->
</ul>
<p class="rows__empty" id="emptyState" hidden>No customers match your search.</p>
</section>
<!-- Detail pane -->
<main class="detail" id="detail" aria-label="Customer detail" aria-live="polite">
<header class="detail__head">
<button class="back" id="backBtn" type="button" aria-label="Back to list">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
Back
</button>
<div class="detail__id">
<span class="avatar" id="dAvatar" data-tone="brand" aria-hidden="true">AC</span>
<div>
<h2 class="detail__name" id="dName">Acme Atlas Co.</h2>
<p class="detail__sub" id="dSub">Enterprise · Seattle, US · since 2023</p>
</div>
<span class="badge" id="dBadge" data-status="active">Active</span>
</div>
<div class="detail__actions">
<button class="btn btn--ghost" id="msgBtn" type="button">Message</button>
<button class="btn btn--primary" id="renewBtn" type="button">Renew plan</button>
</div>
</header>
<div class="detail__body">
<!-- KPI tiles -->
<section class="kpis" aria-label="Account metrics">
<article class="kpi">
<header class="kpi__head"><span class="kpi__label">MRR</span><button class="kpi__menu" type="button" aria-label="Options">⋯</button></header>
<strong class="kpi__val" id="kMrr">$12,480</strong>
<span class="kpi__delta is-up" id="kMrrD">▲ 8.4%</span>
<svg class="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"><polyline id="sparkMrr" fill="none" stroke="var(--brand)" stroke-width="2" points=""/></svg>
</article>
<article class="kpi">
<header class="kpi__head"><span class="kpi__label">Seats used</span><button class="kpi__menu" type="button" aria-label="Options">⋯</button></header>
<strong class="kpi__val" id="kSeats">184 / 220</strong>
<span class="kpi__delta is-up" id="kSeatsD">▲ 12 mo</span>
<svg class="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"><polyline id="sparkSeats" fill="none" stroke="var(--accent)" stroke-width="2" points=""/></svg>
</article>
<article class="kpi">
<header class="kpi__head"><span class="kpi__label">Health score</span><button class="kpi__menu" type="button" aria-label="Options">⋯</button></header>
<strong class="kpi__val" id="kHealth">92</strong>
<span class="kpi__delta is-down" id="kHealthD">▼ 3 pts</span>
<svg class="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"><polyline id="sparkHealth" fill="none" stroke="var(--warn)" stroke-width="2" points=""/></svg>
</article>
<article class="kpi">
<header class="kpi__head"><span class="kpi__label">Open tickets</span><button class="kpi__menu" type="button" aria-label="Options">⋯</button></header>
<strong class="kpi__val" id="kTickets">3</strong>
<span class="kpi__delta is-up" id="kTicketsD">▲ live</span>
<svg class="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"><polyline id="sparkTickets" fill="none" stroke="var(--danger)" stroke-width="2" points=""/></svg>
</article>
</section>
<div class="grid">
<!-- Usage chart -->
<article class="card card--wide">
<header class="card__head">
<h3 class="card__title">Monthly usage</h3>
<div class="card__tabs" role="tablist" aria-label="Metric">
<button class="chip is-on" role="tab" aria-selected="true" data-metric="api">API calls</button>
<button class="chip" role="tab" aria-selected="false" data-metric="storage">Storage</button>
</div>
</header>
<div class="chartwrap">
<svg class="chart" id="usageChart" viewBox="0 0 520 200" role="img" aria-label="Monthly usage bar chart">
<g id="usageGrid" class="chart__grid"></g>
<g id="usageBars"></g>
<g id="usageLabels" class="chart__xlabels"></g>
</svg>
</div>
</article>
<!-- Plan mix donut -->
<article class="card">
<header class="card__head">
<h3 class="card__title">Spend mix</h3>
<button class="kpi__menu" type="button" aria-label="Options">⋯</button>
</header>
<div class="donutwrap">
<svg class="donut" viewBox="0 0 120 120" role="img" aria-label="Spend distribution donut chart">
<circle class="donut__track" cx="60" cy="60" r="48" fill="none" stroke-width="16"/>
<circle id="dSeg1" cx="60" cy="60" r="48" fill="none" stroke-width="16" stroke="var(--brand)" stroke-linecap="round" transform="rotate(-90 60 60)"/>
<circle id="dSeg2" cx="60" cy="60" r="48" fill="none" stroke-width="16" stroke="var(--accent)" stroke-linecap="round" transform="rotate(-90 60 60)"/>
<circle id="dSeg3" cx="60" cy="60" r="48" fill="none" stroke-width="16" stroke="var(--warn)" stroke-linecap="round" transform="rotate(-90 60 60)"/>
<text x="60" y="56" class="donut__big" id="donutTotal">$18.6k</text>
<text x="60" y="74" class="donut__sub">/ month</text>
</svg>
<ul class="legend">
<li><span class="dot" style="background:var(--brand)"></span>Platform <b>52%</b></li>
<li><span class="dot" style="background:var(--accent)"></span>Add-ons <b>31%</b></li>
<li><span class="dot" style="background:var(--warn)"></span>Support <b>17%</b></li>
</ul>
</div>
</article>
<!-- Activity timeline -->
<article class="card card--wide">
<header class="card__head">
<h3 class="card__title">Activity timeline</h3>
<button class="btn btn--ghost btn--sm" id="logBtn" type="button">Add note</button>
</header>
<ol class="timeline" id="timeline">
<!-- injected -->
</ol>
</article>
</div>
</div>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>List + detail (master-detail)
A two-pane master-detail layout for a fictional SaaS CRM (Nimbusly). The left column is a scrollable list of customers — each row shows an avatar, name, a status badge (Active / Trial / At risk) and a mini MRR metric with an up/down delta. A search box filters the list live and a segmented control filters by status. The right pane renders the selected account: a header with avatar, plan and location, four KPI tiles (MRR, seats used, health score, open tickets) each with a delta and inline-SVG sparkline, an animated SVG bar chart that toggles between API calls and storage, a spend-mix donut, and a vertical activity timeline.
Selection is fully keyboard-driven: the list is an ARIA listbox, rows carry
role="option" with aria-selected, and Up/Down/Home/End move the selection
while keeping the chosen row scrolled into view. Clicking or pressing Enter
selects a row, repaints every detail widget, and animates the bars and donut
into place. The open-tickets KPI ticks on a timer to simulate live data, and an
“Add note” action prepends an entry to the timeline.
Below ~720px the layout collapses to a single column: the icon rail hides, the
list fills the screen, and selecting a row pushes a full-screen detail view with
a Back button that returns focus to the originating row. Charts are pure inline
SVG and CSS with no libraries, landmarks (nav / main / header) and
focus-visible states keep it accessible, and a small toast() helper confirms
actions.