Widget — Empty / loading widget states
A single dashboard widget shown through its full lifecycle for the fictional Meridian Ops workspace — a shimmering skeleton while data loads, a no-data-yet empty state with an inline-SVG illustration and connect-a-source CTA, an error state with a retry button and request details, and a loaded state with a real inline-SVG bar chart, KPI value, delta chip and sparkline. A segmented control cycles all four, while Simulate load runs a true loading-to-loaded sequence and the loaded chart ticks live. Pure vanilla, no chart 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);
}
* { 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, h3, p { margin: 0; }
a { color: inherit; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- Shell ---------- */
.shell {
display: grid;
grid-template-columns: 256px 1fr;
min-height: 100vh;
}
/* ---------- Sidebar ---------- */
.sidebar {
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 16px;
background: var(--white);
border-right: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px;
}
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, var(--brand), var(--accent));
color: #fff;
font-weight: 800;
font-size: 17px;
}
.brand-name { font-weight: 700; font-size: 16px; }
.brand-name em { font-style: normal; color: var(--muted); font-weight: 600; }
.nav-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.nav-link {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 11px;
border-radius: var(--r-sm);
text-decoration: none;
color: var(--ink-2);
font-weight: 500;
font-size: 14px;
transition: background 0.16s, color 0.16s;
}
.nav-link .ni { color: var(--muted); font-size: 13px; width: 16px; text-align: center; }
.nav-link:hover { background: var(--brand-50); color: var(--ink); }
.nav-link.is-active { background: var(--brand-50); color: var(--brand-700); font-weight: 600; }
.nav-link.is-active .ni { color: var(--brand); }
.nav-foot { margin-top: auto; border-top: 1px solid var(--line); padding-top: 14px; }
.who { display: flex; align-items: center; gap: 10px; }
.avatar {
display: grid; place-items: center;
width: 34px; height: 34px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--brand-700);
font-weight: 700; font-size: 12px;
}
.who-meta { display: flex; flex-direction: column; line-height: 1.25; }
.who-meta strong { font-size: 13px; }
.who-meta small { font-size: 12px; color: var(--muted); }
/* ---------- Main ---------- */
.main-wrap { display: flex; flex-direction: column; min-width: 0; }
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: 16px;
padding: 14px 24px;
background: rgba(246, 247, 251, 0.86);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.eyebrow { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--brand); }
.head-titles h1 { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
.head-tools { margin-left: auto; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.icon-btn {
display: none;
width: 38px; height: 38px;
border: 1px solid var(--line); border-radius: var(--r-sm);
background: var(--white); color: var(--ink);
font-size: 16px; cursor: pointer;
}
/* segmented control */
.seg { display: inline-flex; background: var(--white); border: 1px solid var(--line); border-radius: 10px; padding: 3px; gap: 2px; }
.seg-btn {
border: 0; background: transparent;
padding: 7px 13px; border-radius: 7px;
font: inherit; font-size: 13px; font-weight: 600; color: var(--muted);
cursor: pointer; transition: background 0.15s, color 0.15s;
}
.seg-btn:hover { color: var(--ink); }
.seg-btn.is-active { background: var(--brand); color: #fff; box-shadow: var(--sh-1); }
/* buttons */
.btn {
display: inline-flex; align-items: center; gap: 7px;
border: 1px solid transparent; border-radius: var(--r-sm);
padding: 9px 15px; font: inherit; font-size: 13px; font-weight: 600;
cursor: pointer; transition: background 0.15s, border-color 0.15s, transform 0.06s;
}
.btn:active { transform: translateY(1px); }
.btn-ico { font-size: 14px; }
.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(--brand-50); color: var(--brand-700); border-color: var(--brand-50); }
/* ---------- Content ---------- */
.content { padding: 24px; display: flex; flex-direction: column; gap: 18px; }
.board-intro { color: var(--muted); font-size: 14px; max-width: 70ch; }
.board-intro strong { color: var(--ink-2); }
.board {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 18px;
align-items: start;
}
/* ---------- Widget card ---------- */
.widget {
grid-column: span 4;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
display: flex; flex-direction: column;
min-height: 318px;
overflow: hidden;
}
.hero-widget { grid-column: span 4; }
.w-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px; padding: 16px 18px 12px;
}
.w-title { display: flex; align-items: center; gap: 10px; }
.w-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--brand); margin-top: 6px; }
.w-dot.dot-accent { background: var(--accent); }
.w-dot.dot-warn { background: var(--warn); }
.w-title h2 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.w-sub { font-size: 12px; color: var(--muted); }
.w-tools { display: flex; align-items: center; gap: 8px; position: relative; }
.state-pill {
font-size: 11px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase;
padding: 3px 9px; border-radius: 999px;
background: var(--brand-50); color: var(--brand-700);
}
.state-pill[data-tone="muted"] { background: rgba(16, 19, 34, 0.06); color: var(--muted); }
.state-pill[data-tone="danger"] { background: #fbe9e6; color: var(--danger); }
.state-pill[data-tone="ok"] { background: rgba(47, 158, 111, 0.14); color: var(--ok); }
.dots {
border: 0; background: transparent; cursor: pointer;
width: 28px; height: 28px; border-radius: var(--r-sm);
color: var(--muted); font-size: 18px; line-height: 1;
display: grid; place-items: center;
}
.dots:hover { background: var(--brand-50); color: var(--brand-700); }
.menu {
position: absolute; top: 34px; right: 0; z-index: 30;
min-width: 168px; padding: 6px;
background: var(--white); border: 1px solid var(--line-2);
border-radius: var(--r-md); box-shadow: var(--sh-2);
display: flex; flex-direction: column; gap: 1px;
}
.menu[hidden] { display: none; }
.menu button {
border: 0; background: transparent; text-align: left;
padding: 8px 10px; border-radius: var(--r-sm);
font: inherit; font-size: 13px; color: var(--ink-2); cursor: pointer;
}
.menu button:hover { background: var(--brand-50); color: var(--brand-700); }
.w-body { flex: 1; padding: 4px 18px 16px; display: flex; flex-direction: column; min-height: 0; }
.w-foot {
display: flex; align-items: center; justify-content: space-between;
padding: 11px 18px; border-top: 1px solid var(--line);
font-size: 12px; color: var(--muted);
}
.foot-status { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; }
.fs-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.foot-status.ok { color: var(--ok); } .foot-status.ok .fs-dot { background: var(--ok); }
.foot-status.warn { color: var(--warn); } .foot-status.warn .fs-dot { background: var(--warn); }
.foot-status.live { color: var(--brand-700); } .foot-status.live .fs-dot { background: var(--brand); animation: pulse 1.6s infinite; }
.foot-status.err { color: var(--danger); } .foot-status.err .fs-dot { background: var(--danger); }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(91,91,240,.45); } 50% { box-shadow: 0 0 0 4px rgba(91,91,240,0); } }
/* ---------- States ---------- */
.state { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.state[hidden] { display: none; }
/* LOADED */
.state-loaded { display: flex; flex-direction: column; gap: 12px; flex: 1; }
.kpi { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
.kpi-figure { display: flex; align-items: baseline; gap: 6px; }
.kpi-value { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; line-height: 1; }
.kpi-unit { font-size: 12px; color: var(--muted); font-weight: 600; }
.kpi-unit-inline { font-size: 16px; color: var(--muted); font-weight: 600; margin-left: 2px; }
.kpi-meta { display: flex; align-items: center; gap: 10px; }
.delta-chip {
display: inline-flex; align-items: center; gap: 3px;
font-size: 12px; font-weight: 700;
padding: 3px 8px; border-radius: 999px;
}
.delta-chip .delta-arrow { font-size: 9px; }
.delta-chip.up { background: rgba(47, 158, 111, 0.14); color: var(--ok); }
.delta-chip.down { background: #fbe9e6; color: var(--danger); }
.kpi-spark { width: 96px; height: 30px; }
.kpi-spark .spark-line { stroke: var(--brand); stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.kpi-spark .spark-fill { fill: var(--brand); opacity: 0.12; }
.chart { margin: 0; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.bars-svg, .area-svg { width: 100%; height: auto; flex: 1; }
.bar-rect { fill: var(--brand); transition: fill 0.15s; }
.bar-rect.peak { fill: var(--accent); }
.bar-rect:hover { fill: var(--brand-d); }
.bar-grid { stroke: var(--line); stroke-width: 1; }
.chart-cap { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--muted); }
.chart-note { font-weight: 600; color: var(--ink-2); }
.swatch { display: inline-block; width: 9px; height: 9px; border-radius: 3px; vertical-align: -1px; margin-right: 4px; }
.swatch-brand { background: var(--brand); }
.swatch-line { background: var(--brand-50); border: 1px solid var(--line-2); }
/* donut */
.donut-wrap { flex-direction: row; align-items: center; gap: 16px; }
.donut { width: 116px; height: 116px; flex: none; }
.donut-num { font-size: 22px; font-weight: 800; fill: var(--ink); }
.donut-cap { font-size: 9px; fill: var(--muted); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
.legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; font-size: 12px; color: var(--ink-2); }
/* ---------- Skeleton / shimmer ---------- */
.state-loading { gap: 16px; padding-top: 6px; }
.sk-kpi { display: flex; align-items: center; justify-content: space-between; }
.sk-line { display: block; height: 12px; border-radius: 6px; background: rgba(16, 19, 34, 0.08); }
.sk-value { width: 130px; height: 30px; border-radius: 8px; }
.sk-chip { width: 56px; height: 20px; border-radius: 999px; }
.sk-chart { flex: 1; display: flex; align-items: flex-end; gap: 8px; min-height: 92px; }
.sk-bar { flex: 1; border-radius: 6px 6px 3px 3px; background: rgba(16, 19, 34, 0.08); min-height: 12px; }
.sk-foot { display: flex; align-items: center; justify-content: space-between; }
.shimmer {
position: relative;
overflow: hidden;
background-image: linear-gradient(90deg, rgba(16,19,34,0.08) 0%, rgba(16,19,34,0.08) 40%, rgba(91,91,240,0.14) 50%, rgba(16,19,34,0.08) 60%, rgba(16,19,34,0.08) 100%);
background-size: 220% 100%;
animation: shimmer 1.25s ease-in-out infinite;
}
@keyframes shimmer { 0% { background-position: 140% 0; } 100% { background-position: -40% 0; } }
/* ---------- Empty ---------- */
.state-empty, .state-error {
align-items: center; justify-content: center; text-align: center;
gap: 6px; padding: 8px 10px 14px;
}
.empty-art { width: 152px; height: auto; margin-bottom: 4px; }
.empty-art .empty-bars rect { transform-origin: bottom; animation: rise 0.5s ease both; }
.empty-art .empty-bars rect:nth-child(2) { animation-delay: 0.06s; }
.empty-art .empty-bars rect:nth-child(3) { animation-delay: 0.12s; }
.empty-art .empty-bars rect:nth-child(4) { animation-delay: 0.18s; }
@keyframes rise { from { transform: scaleY(0.2); opacity: 0; } to { transform: scaleY(1); opacity: 1; } }
.empty-title { font-size: 16px; font-weight: 700; }
.empty-text { font-size: 13px; color: var(--muted); max-width: 34ch; }
.empty-cta { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; justify-content: center; }
/* ---------- Error ---------- */
.error-art { width: 60px; height: 60px; margin-bottom: 4px; }
.error-title { font-size: 16px; font-weight: 700; }
.error-text { font-size: 13px; color: var(--muted); max-width: 36ch; }
.error-text code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; background: rgba(16,19,34,0.06); padding: 1px 5px; border-radius: 5px; color: var(--ink-2); }
.error-cta { display: flex; gap: 8px; margin-top: 8px; }
/* fade between states */
.state:not([hidden]) { animation: fade 0.28s ease; }
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* ---------- Scrim / Toast ---------- */
.scrim {
position: fixed; inset: 0; z-index: 15;
background: rgba(16, 19, 34, 0.4);
}
.scrim[hidden] { display: none; }
.toast-wrap {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
z-index: 60; display: flex; flex-direction: column; gap: 8px; align-items: center;
pointer-events: none;
}
.toast {
background: var(--ink); color: #fff;
padding: 10px 16px; border-radius: 999px;
font-size: 13px; font-weight: 600; box-shadow: var(--sh-2);
animation: toast-in 0.2s ease;
}
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* ---------- Responsive ---------- */
@media (max-width: 1080px) {
.widget, .hero-widget { grid-column: span 6; }
}
@media (max-width: 920px) {
.shell { grid-template-columns: 1fr; }
.sidebar {
position: fixed; inset: 0 auto 0 0; width: 256px; z-index: 40;
transform: translateX(-100%); transition: transform 0.24s ease;
box-shadow: var(--sh-2);
}
.sidebar.is-open { transform: none; }
.icon-btn { display: grid; place-items: center; }
}
@media (max-width: 720px) {
.widget, .hero-widget { grid-column: span 12; }
.head-tools { width: 100%; }
}
@media (max-width: 480px) {
.content { padding: 16px; }
.topbar { padding: 12px 16px; }
.seg { width: 100%; }
.seg-btn { flex: 1; padding: 8px 4px; }
.simulate-fit { width: 100%; justify-content: center; }
.kpi-value { font-size: 26px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
.shimmer { animation: none; background: rgba(16,19,34,0.1); }
}(function () {
"use strict";
var widget = document.getElementById("stateWidget");
var seg = document.getElementById("stateSeg");
var segBtns = Array.prototype.slice.call(seg.querySelectorAll(".seg-btn"));
var panes = Array.prototype.slice.call(widget.querySelectorAll(".state"));
var pill = document.getElementById("statePill");
var footStatus = document.getElementById("footStatus");
var footTime = document.getElementById("footTime");
var simulateBtn = document.getElementById("simulateBtn");
var prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var STATE_META = {
loading: { pill: "Loading", tone: "muted", foot: '<span class="fs-dot"></span> Syncing…', footClass: "foot-status live", time: "—" },
empty: { pill: "Empty", tone: "muted", foot: '<span class="fs-dot"></span> No source connected', footClass: "foot-status", time: "—" },
error: { pill: "Error", tone: "danger", foot: '<span class="fs-dot"></span> Failed to load', footClass: "foot-status err", time: "8s timeout" },
loaded: { pill: "Live", tone: "ok", foot: '<span class="fs-dot"></span> Up to date', footClass: "foot-status live", time: "just now" }
};
/* ---------- toast ---------- */
var toastWrap = document.getElementById("toastWrap");
function toast(msg) {
var t = document.createElement("div");
t.className = "toast";
t.textContent = msg;
toastWrap.appendChild(t);
setTimeout(function () {
t.style.opacity = "0";
t.style.transform = "translateY(8px)";
t.style.transition = "opacity .25s, transform .25s";
setTimeout(function () { t.remove(); }, 260);
}, 2200);
}
/* ---------- state switching ---------- */
function setState(state) {
widget.setAttribute("data-state", state);
panes.forEach(function (p) {
p.hidden = p.getAttribute("data-pane") !== state;
});
segBtns.forEach(function (b) {
var on = b.getAttribute("data-state") === state;
b.classList.toggle("is-active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
var m = STATE_META[state];
if (m) {
pill.textContent = m.pill;
pill.setAttribute("data-tone", m.tone);
footStatus.className = m.footClass;
footStatus.innerHTML = m.foot;
footTime.textContent = m.time;
}
if (state === "loaded") renderLoaded();
}
segBtns.forEach(function (b) {
b.addEventListener("click", function () { setState(b.getAttribute("data-state")); });
});
/* ---------- overflow menu ---------- */
var menuBtn = document.getElementById("wMenuBtn");
var menu = document.getElementById("wMenu");
function openMenu(open) {
menu.hidden = !open;
menuBtn.setAttribute("aria-expanded", open ? "true" : "false");
if (open) {
var first = menu.querySelector("button");
if (first) first.focus();
}
}
menuBtn.addEventListener("click", function (e) {
e.stopPropagation();
openMenu(menu.hidden);
});
menu.addEventListener("click", function (e) {
var b = e.target.closest("button[data-go]");
if (!b) return;
openMenu(false);
menuBtn.focus();
if (b.getAttribute("data-go") === "loaded") {
simulate();
} else {
setState(b.getAttribute("data-go"));
}
});
document.addEventListener("click", function () { if (!menu.hidden) openMenu(false); });
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !menu.hidden) { openMenu(false); menuBtn.focus(); }
});
/* ---------- simulate loading -> loaded ---------- */
var loadingTimer = null;
function simulate() {
if (loadingTimer) clearTimeout(loadingTimer);
setState("loading");
document.getElementById("wSub").textContent = "Fetching readings…";
var delay = prefersReduced ? 350 : 1500;
loadingTimer = setTimeout(function () {
document.getElementById("wSub").textContent = "Realtime · last 24h";
setState("loaded");
toast("Widget loaded · 8,412 sessions");
}, delay);
}
simulateBtn.addEventListener("click", simulate);
/* ---------- empty / error CTAs ---------- */
document.getElementById("connectBtn").addEventListener("click", function () {
toast("Connecting source…");
simulate();
});
document.getElementById("emptyDocsBtn").addEventListener("click", function () {
toast("Opening docs for session metrics");
});
document.getElementById("retryBtn").addEventListener("click", function () {
toast("Retrying request…");
simulate();
});
document.getElementById("errDetailsBtn").addEventListener("click", function () {
toast("ECONNRESET · metrics/sessions · req 7f3a9");
});
/* ---------- loaded chart + KPI rendering ---------- */
var SVGNS = "http://www.w3.org/2000/svg";
var sessions = [];
function seedSessions() {
sessions = [];
var base = 420;
for (var i = 0; i < 12; i++) {
var hour = i + 1;
var bell = Math.exp(-Math.pow((i - 7) / 4, 2));
var v = Math.round(base + bell * 900 + (Math.random() * 120 - 60));
sessions.push({ hour: hour, v: Math.max(180, v) });
}
}
function renderBars() {
var svg = document.getElementById("barsSvg");
while (svg.firstChild) svg.removeChild(svg.firstChild);
var W = 480, H = 160, padB = 22, padT = 8;
var max = sessions.reduce(function (m, d) { return Math.max(m, d.v); }, 0);
var peakIdx = sessions.reduce(function (pi, d, i) { return d.v > sessions[pi].v ? i : pi; }, 0);
// baseline grid
[0.25, 0.5, 0.75].forEach(function (g) {
var y = padT + (H - padB - padT) * g;
var line = document.createElementNS(SVGNS, "line");
line.setAttribute("x1", 0); line.setAttribute("x2", W);
line.setAttribute("y1", y); line.setAttribute("y2", y);
line.setAttribute("class", "bar-grid");
svg.appendChild(line);
});
var gap = 8;
var bw = (W - gap * (sessions.length - 1)) / sessions.length;
sessions.forEach(function (d, i) {
var h = (d.v / max) * (H - padB - padT);
var x = i * (bw + gap);
var y = H - padB - h;
var r = document.createElementNS(SVGNS, "rect");
r.setAttribute("x", x.toFixed(1));
r.setAttribute("y", y.toFixed(1));
r.setAttribute("width", bw.toFixed(1));
r.setAttribute("height", Math.max(2, h).toFixed(1));
r.setAttribute("rx", "4");
r.setAttribute("class", "bar-rect" + (i === peakIdx ? " peak" : ""));
var title = document.createElementNS(SVGNS, "title");
title.textContent = String(d.hour).padStart(2, "0") + ":00 · " + d.v.toLocaleString() + " sessions";
r.appendChild(title);
svg.appendChild(r);
});
var note = widget.querySelector(".chart-note");
if (note) note.textContent = "Peak " + String(sessions[peakIdx].hour).padStart(2, "0") + ":00 · " + sessions[peakIdx].v.toLocaleString();
}
function renderSpark() {
var line = document.getElementById("sparkLine");
var fill = document.getElementById("sparkFill");
var W = 96, H = 30;
var pts = sessions.slice(-8).map(function (d) { return d.v; });
var max = Math.max.apply(null, pts), min = Math.min.apply(null, pts);
var span = Math.max(1, max - min);
var step = W / (pts.length - 1);
var d = pts.map(function (v, i) {
var x = i * step;
var y = H - 3 - ((v - min) / span) * (H - 6);
return (i ? "L" : "M") + x.toFixed(1) + " " + y.toFixed(1);
}).join(" ");
line.setAttribute("d", d);
fill.setAttribute("d", d + " L" + W + " " + H + " L0 " + H + " Z");
}
function renderLoaded() {
if (!sessions.length) seedSessions();
var total = sessions.reduce(function (s, d) { return s + d.v; }, 0);
document.getElementById("kpiValue").textContent = total.toLocaleString();
var delta = (Math.random() * 8 + 2);
var up = Math.random() > 0.25;
var chip = document.getElementById("kpiDelta");
chip.className = "delta-chip " + (up ? "up" : "down");
chip.querySelector(".delta-arrow").textContent = up ? "▲" : "▼";
document.getElementById("kpiDeltaPct").textContent = delta.toFixed(1) + "%";
renderBars();
renderSpark();
footTime.textContent = "just now";
}
/* ---------- live tick (only while loaded) ---------- */
setInterval(function () {
if (widget.getAttribute("data-state") !== "loaded" || prefersReduced) return;
// nudge the latest few windows so the board feels live
var i = sessions.length - 1;
if (i < 0) return;
sessions[i].v = Math.max(180, sessions[i].v + Math.round(Math.random() * 80 - 38));
var total = sessions.reduce(function (s, d) { return s + d.v; }, 0);
document.getElementById("kpiValue").textContent = total.toLocaleString();
renderBars();
renderSpark();
}, 2400);
/* ---------- off-canvas nav ---------- */
var sidebar = document.getElementById("sidebar");
var menuToggle = document.getElementById("menuToggle");
var scrim = document.getElementById("scrim");
function setNav(open) {
sidebar.classList.toggle("is-open", open);
scrim.hidden = !open;
menuToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
menuToggle.addEventListener("click", function () { setNav(!sidebar.classList.contains("is-open")); });
scrim.addEventListener("click", function () { setNav(false); });
/* ---------- init ---------- */
seedSessions();
setState("loading");
// kick off an automatic simulated load on first paint
setTimeout(simulate, 600);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meridian Ops — Widget states</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">
<!-- Sidebar -->
<nav class="sidebar" id="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark" aria-hidden="true">M</span>
<span class="brand-name">Meridian<em>Ops</em></span>
</div>
<ul class="nav-list">
<li><a href="#" class="nav-link is-active" aria-current="page"><span class="ni" aria-hidden="true">◧</span> Overview</a></li>
<li><a href="#" class="nav-link"><span class="ni" aria-hidden="true">▤</span> Widgets</a></li>
<li><a href="#" class="nav-link"><span class="ni" aria-hidden="true">◔</span> Sources</a></li>
<li><a href="#" class="nav-link"><span class="ni" aria-hidden="true">◇</span> Alerts</a></li>
<li><a href="#" class="nav-link"><span class="ni" aria-hidden="true">⚙</span> Settings</a></li>
</ul>
<div class="nav-foot">
<div class="who">
<span class="avatar" aria-hidden="true">RT</span>
<span class="who-meta"><strong>Rina Toft</strong><small>Platform</small></span>
</div>
</div>
</nav>
<div class="main-wrap">
<!-- Topbar -->
<header class="topbar">
<button class="icon-btn menu-toggle" id="menuToggle" aria-label="Toggle navigation" aria-expanded="false">☰</button>
<div class="head-titles">
<p class="eyebrow">Widget kit · states</p>
<h1>Empty & loading widget states</h1>
</div>
<div class="head-tools">
<div class="seg" role="group" aria-label="Switch widget state" id="stateSeg">
<button class="seg-btn is-active" data-state="loading" aria-pressed="true">Loading</button>
<button class="seg-btn" data-state="empty" aria-pressed="false">Empty</button>
<button class="seg-btn" data-state="error" aria-pressed="false">Error</button>
<button class="seg-btn" data-state="loaded" aria-pressed="false">Loaded</button>
</div>
<button class="btn btn-primary" id="simulateBtn" type="button">
<span class="btn-ico" aria-hidden="true">↻</span> Simulate load
</button>
</div>
</header>
<main class="content" aria-labelledby="boardTitle">
<p class="board-intro" id="boardTitle">
One widget, four states. Use the switcher or <strong>Simulate load</strong> to run a real loading → loaded sequence with shimmer.
</p>
<section class="board" aria-label="Widget board">
<!-- ====== HERO WIDGET (state-driven) ====== -->
<article class="widget hero-widget" id="stateWidget" data-state="loading" aria-live="polite">
<header class="w-head">
<div class="w-title">
<span class="w-dot" aria-hidden="true"></span>
<div>
<h2>Active sessions</h2>
<p class="w-sub" id="wSub">Realtime · last 24h</p>
</div>
</div>
<div class="w-tools">
<span class="state-pill" id="statePill" data-tone="muted">Loading</span>
<button class="dots" id="wMenuBtn" aria-haspopup="true" aria-expanded="false" aria-label="Widget options">⋯</button>
<div class="menu" id="wMenu" role="menu" hidden>
<button role="menuitem" data-go="loading">Show loading</button>
<button role="menuitem" data-go="empty">Show empty</button>
<button role="menuitem" data-go="error">Show error</button>
<button role="menuitem" data-go="loaded">Show loaded</button>
</div>
</div>
</header>
<div class="w-body">
<!-- LOADING (skeleton shimmer) -->
<div class="state state-loading" data-pane="loading">
<div class="sk sk-kpi">
<span class="sk-line sk-value shimmer"></span>
<span class="sk-line sk-chip shimmer"></span>
</div>
<div class="sk-chart">
<span class="sk-bar shimmer" style="height:42%"></span>
<span class="sk-bar shimmer" style="height:66%"></span>
<span class="sk-bar shimmer" style="height:38%"></span>
<span class="sk-bar shimmer" style="height:74%"></span>
<span class="sk-bar shimmer" style="height:54%"></span>
<span class="sk-bar shimmer" style="height:82%"></span>
<span class="sk-bar shimmer" style="height:48%"></span>
<span class="sk-bar shimmer" style="height:70%"></span>
<span class="sk-bar shimmer" style="height:60%"></span>
<span class="sk-bar shimmer" style="height:88%"></span>
</div>
<div class="sk-foot">
<span class="sk-line shimmer" style="width:46%"></span>
<span class="sk-line shimmer" style="width:24%"></span>
</div>
</div>
<!-- EMPTY (illustration + CTA) -->
<div class="state state-empty" data-pane="empty" hidden>
<svg class="empty-art" viewBox="0 0 160 120" role="img" aria-label="No data illustration">
<defs>
<linearGradient id="eg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#eef0ff"/>
<stop offset="1" stop-color="#d8f5f2"/>
</linearGradient>
</defs>
<rect x="22" y="26" width="116" height="72" rx="10" fill="url(#eg)" stroke="var(--line-2)"/>
<rect x="34" y="40" width="92" height="8" rx="4" fill="var(--white)" stroke="var(--line)"/>
<rect x="34" y="56" width="60" height="8" rx="4" fill="var(--white)" stroke="var(--line)"/>
<g class="empty-bars">
<rect x="40" y="80" width="12" height="8" rx="2" fill="var(--brand)" opacity=".35"/>
<rect x="58" y="74" width="12" height="14" rx="2" fill="var(--brand)" opacity=".5"/>
<rect x="76" y="78" width="12" height="10" rx="2" fill="var(--brand)" opacity=".4"/>
<rect x="94" y="70" width="12" height="18" rx="2" fill="var(--accent)" opacity=".6"/>
</g>
<circle cx="118" cy="34" r="16" fill="var(--white)" stroke="var(--line-2)"/>
<path d="M112 34h12M118 28v12" stroke="var(--muted)" stroke-width="2.4" stroke-linecap="round"/>
</svg>
<h3 class="empty-title">No data yet</h3>
<p class="empty-text">This widget has no readings for the selected window. Connect a source to start collecting session metrics.</p>
<div class="empty-cta">
<button class="btn btn-primary" id="connectBtn" type="button">Connect a source</button>
<button class="btn btn-ghost" id="emptyDocsBtn" type="button">View docs</button>
</div>
</div>
<!-- ERROR (retry) -->
<div class="state state-error" data-pane="error" hidden>
<svg class="error-art" viewBox="0 0 64 64" role="img" aria-label="Error illustration">
<circle cx="32" cy="32" r="26" fill="#fbe9e6" stroke="var(--danger)" stroke-opacity=".4"/>
<path d="M32 20v18" stroke="var(--danger)" stroke-width="4" stroke-linecap="round"/>
<circle cx="32" cy="46" r="2.6" fill="var(--danger)"/>
</svg>
<h3 class="error-title">Couldn’t load this widget</h3>
<p class="error-text" id="errText">Request to <code>metrics/sessions</code> timed out after 8s. Check the source connection and try again.</p>
<div class="error-cta">
<button class="btn btn-primary" id="retryBtn" type="button">
<span class="btn-ico" aria-hidden="true">↻</span> Retry
</button>
<button class="btn btn-ghost" id="errDetailsBtn" type="button">Details</button>
</div>
</div>
<!-- LOADED (real chart + KPI) -->
<div class="state state-loaded" data-pane="loaded" hidden>
<div class="kpi">
<div class="kpi-figure">
<span class="kpi-value" id="kpiValue">8,412</span>
<span class="kpi-unit">sessions</span>
</div>
<div class="kpi-meta">
<span class="delta-chip up" id="kpiDelta">
<span class="delta-arrow" aria-hidden="true">▲</span><span id="kpiDeltaPct">6.2%</span>
</span>
<svg class="kpi-spark" viewBox="0 0 96 30" preserveAspectRatio="none" aria-hidden="true">
<path class="spark-fill" id="sparkFill" d=""/>
<path class="spark-line" id="sparkLine" d=""/>
</svg>
</div>
</div>
<figure class="chart" aria-label="Sessions per hour, bar chart">
<svg class="bars-svg" id="barsSvg" viewBox="0 0 480 160" role="img" aria-label="Bar chart of sessions per hour over 12 windows"></svg>
<figcaption class="chart-cap">
<span><span class="swatch swatch-brand"></span> Sessions / hour</span>
<span class="chart-note">Peak 11:00 · 1,204</span>
</figcaption>
</figure>
</div>
</div>
<footer class="w-foot">
<span class="foot-status" id="footStatus"><span class="fs-dot" aria-hidden="true"></span> Syncing…</span>
<span class="foot-time" id="footTime">—</span>
</footer>
</article>
<!-- ====== STATIC REFERENCE WIDGETS ====== -->
<article class="widget">
<header class="w-head">
<div class="w-title">
<span class="w-dot dot-accent" aria-hidden="true"></span>
<div><h2>Error budget</h2><p class="w-sub">Rolling 30 days</p></div>
</div>
<button class="dots" aria-label="Options">⋯</button>
</header>
<div class="w-body">
<div class="state-loaded">
<div class="kpi">
<div class="kpi-figure"><span class="kpi-value">71.4%</span><span class="kpi-unit">remaining</span></div>
<div class="kpi-meta">
<span class="delta-chip down"><span class="delta-arrow" aria-hidden="true">▼</span>4.1%</span>
</div>
</div>
<figure class="chart donut-wrap" aria-label="Error budget donut, 71.4 percent remaining">
<svg class="donut" viewBox="0 0 120 120" role="img" aria-label="Donut showing 71.4 percent budget remaining">
<circle cx="60" cy="60" r="48" fill="none" stroke="var(--brand-50)" stroke-width="16"/>
<circle cx="60" cy="60" r="48" fill="none" stroke="var(--brand)" stroke-width="16"
stroke-linecap="round" stroke-dasharray="215.4 301.6"
transform="rotate(-90 60 60)"/>
<text x="60" y="58" text-anchor="middle" class="donut-num">71%</text>
<text x="60" y="74" text-anchor="middle" class="donut-cap">budget</text>
</svg>
<ul class="legend">
<li><span class="swatch swatch-brand"></span> Remaining 71.4%</li>
<li><span class="swatch swatch-line"></span> Burned 28.6%</li>
</ul>
</figure>
</div>
</div>
<footer class="w-foot"><span class="foot-status ok"><span class="fs-dot" aria-hidden="true"></span> Healthy</span><span class="foot-time">2m ago</span></footer>
</article>
<article class="widget">
<header class="w-head">
<div class="w-title">
<span class="w-dot dot-warn" aria-hidden="true"></span>
<div><h2>Latency p95</h2><p class="w-sub">All regions</p></div>
</div>
<button class="dots" aria-label="Options">⋯</button>
</header>
<div class="w-body">
<div class="state-loaded">
<div class="kpi">
<div class="kpi-figure"><span class="kpi-value">214<span class="kpi-unit-inline">ms</span></span></div>
<div class="kpi-meta">
<span class="delta-chip up"><span class="delta-arrow" aria-hidden="true">▲</span>9ms</span>
<svg class="kpi-spark" viewBox="0 0 96 30" preserveAspectRatio="none" aria-hidden="true">
<path class="spark-line" d="M0 22 L11 18 L21 24 L32 14 L43 19 L53 11 L64 16 L75 9 L85 13 L96 6" fill="none"/>
</svg>
</div>
</div>
<figure class="chart" aria-label="Latency trend, area line">
<svg class="area-svg" viewBox="0 0 480 120" preserveAspectRatio="none" role="img" aria-label="Area line of p95 latency">
<defs>
<linearGradient id="ag" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--brand)" stop-opacity=".24"/>
<stop offset="1" stop-color="var(--brand)" stop-opacity="0"/>
</linearGradient>
</defs>
<path fill="url(#ag)" d="M0 86 L40 78 L80 90 L120 64 L160 72 L200 50 L240 66 L280 40 L320 58 L360 36 L400 52 L440 30 L480 44 L480 120 L0 120 Z"/>
<path fill="none" stroke="var(--brand)" stroke-width="2.5" d="M0 86 L40 78 L80 90 L120 64 L160 72 L200 50 L240 66 L280 40 L320 58 L360 36 L400 52 L440 30 L480 44"/>
</svg>
</figure>
</div>
</div>
<footer class="w-foot"><span class="foot-status warn"><span class="fs-dot" aria-hidden="true"></span> Watching</span><span class="foot-time">just now</span></footer>
</article>
</section>
</main>
</div>
</div>
<div class="scrim" id="scrim" hidden></div>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Empty / loading widget states
A reference for the four states every data widget needs to handle gracefully. One Active sessions card on the fictional Meridian Ops board is driven through loading, empty, error, and loaded — each with its own polished treatment: a shimmering skeleton (KPI line, bars, footer), a no-data illustration with a Connect a source CTA, an error panel with a Retry button and the failing request, and a fully rendered state with a KPI headline, an up/down delta chip, a sparkline, and an inline-SVG bar chart of sessions per hour. Two static reference widgets (an error-budget donut and a p95 latency area line) round out the board.
A segmented control in the header switches the live widget between states instantly, and a matching overflow menu inside the card does the same from the ⋯ button. Simulate load runs the real sequence — it drops into the shimmering skeleton, waits, then animates into the loaded chart and fires a toast — and the empty-state CTA and error Retry both route through that same loading path so the transitions stay honest.
Once loaded, the bar chart and KPI tick on a timer so the board feels live, with the peak hour highlighted and per-bar tooltips. Everything is keyboard-operable with visible focus rings and prefers-reduced-motion support; the layout uses landmark roles, collapses from a twelve-column grid to a single column, and swaps in off-canvas navigation below ~920px. No chart libraries, no images, no placeholder gray boxes — just inline SVG and CSS.