Widget — Resizable widget card frame
A reusable dashboard widget card frame for a fictional ops workspace — each card carries a title, subtitle, an S/M/L size toggle that smoothly re-spans the grid, a body slot with an inline-SVG chart (area line, channel bars, or plan-mix donut), a KPI headline with an up/down delta chip, and a synced-status footer. A keyboard-friendly overflow menu opens a popover with Refresh, Expand, and Remove; refresh swaps in fresh data behind a spinner, expand lifts the chart into a modal, and cards drag to rearrange. 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, body { margin: 0; padding: 0; }
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, p { margin: 0; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 4px;
}
/* ---- Layout ---- */
.app {
display: grid;
grid-template-columns: 256px 1fr;
min-height: 100vh;
}
/* ---- Sidebar ---- */
.sidebar {
background: var(--white);
border-right: 1px solid var(--line);
padding: 22px 16px;
display: flex;
flex-direction: column;
gap: 18px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
font-size: 16px;
font-weight: 800;
}
.brand-mark {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
background: linear-gradient(135deg, var(--brand), var(--accent));
color: #fff;
font-size: 14px;
}
.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-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 12px;
border-radius: var(--r-sm);
color: var(--ink-2);
text-decoration: none;
font-size: 14px;
font-weight: 500;
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; }
.nav-ico { width: 18px; text-align: center; opacity: 0.8; }
.nav-foot {
margin-top: auto;
display: flex;
align-items: center;
gap: 11px;
padding: 12px 10px;
border-top: 1px solid var(--line);
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--brand);
color: #fff;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.who { display: flex; flex-direction: column; line-height: 1.25; }
.who strong { font-size: 13.5px; }
.who span { font-size: 12px; color: var(--muted); }
/* ---- Topbar ---- */
.main-col { display: flex; flex-direction: column; min-width: 0; }
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 28px;
background: var(--white);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 20;
}
.head-text h1 { font-size: 21px; font-weight: 800; letter-spacing: -0.01em; }
.head-text p { font-size: 13px; color: var(--muted); margin-top: 2px; }
.head-tools { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.range {
position: relative;
display: inline-flex;
align-items: center;
background: var(--white);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 0 10px 0 4px;
}
.range-ico { color: var(--muted); font-size: 11px; order: 2; pointer-events: none; }
.range select {
appearance: none;
border: 0;
background: transparent;
font: inherit;
font-size: 13.5px;
font-weight: 500;
color: var(--ink);
padding: 8px 8px 8px 10px;
cursor: pointer;
}
.btn-primary {
border: 0;
background: var(--brand);
color: #fff;
font: inherit;
font-size: 13.5px;
font-weight: 600;
padding: 9px 16px;
border-radius: var(--r-sm);
cursor: pointer;
box-shadow: var(--sh-1);
transition: background 0.15s, transform 0.05s;
}
.btn-primary:hover { background: var(--brand-d); }
.btn-primary:active { transform: translateY(1px); }
.icon-btn {
border: 1px solid transparent;
background: transparent;
color: var(--ink-2);
width: 34px;
height: 34px;
border-radius: var(--r-sm);
font-size: 17px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: background 0.15s, border-color 0.15s;
}
.icon-btn:hover { background: var(--bg); border-color: var(--line); }
.nav-toggle { display: none; }
/* ---- Board grid ---- */
.board {
padding: 26px 28px 48px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 14px;
gap: 20px;
align-content: start;
}
/* ---- Widget frame ---- */
.widget {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
display: flex;
flex-direction: column;
overflow: hidden;
transition: grid-column 0.35s ease, grid-row 0.35s ease, box-shadow 0.2s, opacity 0.2s, transform 0.2s, border-color 0.2s;
}
.widget:hover { box-shadow: var(--sh-2); }
/* size spans (cols x row-units of 14px) */
.widget.size-s { grid-column: span 1; grid-row: span 16; }
.widget.size-m { grid-column: span 2; grid-row: span 16; }
.widget.size-l { grid-column: span 2; grid-row: span 24; }
.widget.is-dragging { opacity: 0.45; }
.widget.drop-target { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
.widget.is-removing { opacity: 0; transform: scale(0.94); }
.w-head {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--line);
}
.w-title h2 { font-size: 14.5px; font-weight: 700; letter-spacing: -0.01em; }
.w-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
.w-controls { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.size-toggle {
display: inline-flex;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 2px;
}
.size-toggle button {
border: 0;
background: transparent;
font: inherit;
font-size: 11.5px;
font-weight: 700;
color: var(--muted);
width: 24px;
height: 22px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.size-toggle button:hover { color: var(--ink); }
.size-toggle button[aria-pressed="true"] {
background: var(--white);
color: var(--brand-700);
box-shadow: var(--sh-1);
}
/* ---- Menu popover ---- */
.menu-wrap { position: relative; }
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
min-width: 158px;
background: var(--white);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
box-shadow: var(--sh-2);
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.menu[hidden] { display: none; }
.menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
border: 0;
background: transparent;
font: inherit;
font-size: 13.5px;
font-weight: 500;
color: var(--ink-2);
padding: 8px 10px;
border-radius: var(--r-sm);
cursor: pointer;
text-align: left;
}
.menu button span { width: 16px; text-align: center; opacity: 0.85; }
.menu button:hover { background: var(--bg); color: var(--ink); }
.menu button.is-danger { color: var(--danger); }
.menu button.is-danger:hover { background: #fdeceA; background: rgba(212, 80, 62, 0.08); }
/* ---- Body ---- */
.w-body {
flex: 1;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
position: relative;
}
.w-figure { display: flex; flex-direction: column; gap: 2px; }
.w-value {
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
display: flex;
align-items: baseline;
gap: 9px;
}
.size-s .w-value { font-size: 22px; }
.w-delta {
font-size: 12px;
font-weight: 700;
padding: 2px 7px;
border-radius: 999px;
}
.w-delta.is-up { color: var(--ok); background: rgba(47, 158, 111, 0.12); }
.w-delta.is-down { color: var(--danger); background: rgba(212, 80, 62, 0.12); }
.w-cap { font-size: 12px; color: var(--muted); }
.chart-slot { flex: 1; min-height: 84px; display: flex; }
.chart-slot svg { width: 100%; height: 100%; display: block; }
/* spinner overlay */
.w-loading {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.74);
display: grid;
place-items: center;
z-index: 5;
backdrop-filter: blur(1px);
}
.spinner {
width: 30px;
height: 30px;
border-radius: 50%;
border: 3px solid var(--line-2);
border-top-color: var(--brand);
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- Footer ---- */
.w-foot {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 16px;
border-top: 1px solid var(--line);
font-size: 12px;
color: var(--muted);
font-weight: 500;
}
.w-foot-r { margin-left: auto; font-weight: 600; color: var(--ink-2); }
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-ok { background: var(--ok); box-shadow: 0 0 0 3px rgba(47, 158, 111, 0.16); }
.dot-warn { background: var(--warn); box-shadow: 0 0 0 3px rgba(217, 138, 43, 0.16); }
/* ---- Donut legend ---- */
.donut-wrap { display: flex; align-items: center; gap: 16px; width: 100%; }
.donut-wrap svg { width: 110px; height: 110px; flex-shrink: 0; }
.legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.legend li { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.legend .swatch { width: 11px; height: 11px; border-radius: 3px; flex-shrink: 0; }
.legend .lv { margin-left: auto; font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }
.legend .lk { color: var(--ink-2); font-weight: 500; }
/* ---- Modal ---- */
.overlay {
position: fixed;
inset: 0;
background: rgba(16, 19, 34, 0.46);
display: grid;
place-items: center;
padding: 24px;
z-index: 100;
animation: fade 0.18s ease;
}
.overlay[hidden] { display: none; }
@keyframes fade { from { opacity: 0; } }
.modal {
background: var(--white);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
width: min(680px, 100%);
max-height: 86vh;
overflow: auto;
animation: pop 0.2s ease;
}
@keyframes pop { from { transform: translateY(12px) scale(0.98); opacity: 0; } }
.modal-head {
display: flex;
align-items: center;
padding: 18px 22px;
border-bottom: 1px solid var(--line);
}
.modal-head h2 { font-size: 17px; font-weight: 800; }
.modal-head .icon-btn { margin-left: auto; }
.modal-body { padding: 22px; }
.modal-body .chart-slot { min-height: 260px; }
.modal-meta { display: flex; gap: 26px; flex-wrap: wrap; margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--line); }
.modal-meta div { display: flex; flex-direction: column; }
.modal-meta dt { font-size: 12px; color: var(--muted); font-weight: 500; }
.modal-meta dd { margin: 2px 0 0; font-size: 19px; font-weight: 800; }
/* ---- Toast ---- */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--ink);
color: #fff;
padding: 11px 18px;
border-radius: 999px;
font-size: 13.5px;
font-weight: 500;
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
z-index: 200;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ---- Responsive ---- */
@media (max-width: 1100px) {
.board { grid-template-columns: repeat(2, 1fr); }
.widget.size-m, .widget.size-l { grid-column: span 2; }
.widget.size-s { grid-column: span 1; }
}
@media (max-width: 720px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 50;
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: var(--sh-2);
width: 248px;
}
.app.nav-open .sidebar { transform: translateX(0); }
.nav-toggle { display: grid; }
.board { grid-template-columns: 1fr; grid-auto-rows: auto; }
.widget, .widget.size-s, .widget.size-m, .widget.size-l { grid-column: 1 / -1; grid-row: auto; }
.head-tools .btn-primary { padding: 9px 12px; }
}
@media (max-width: 400px) {
.topbar { padding: 16px; gap: 10px; }
.board { padding: 16px; }
.head-text p { display: none; }
.donut-wrap { flex-direction: column; align-items: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}/* Widget — Resizable widget card frame
Vanilla JS only. No frameworks, no chart libs. */
(() => {
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
var toastEl = document.getElementById("toast");
var toastTimer = null;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastEl.classList.remove("show");
}, 2200);
}
function rand(min, max) {
return Math.random() * (max - min) + min;
}
function fmtMoney(n) {
return "$" + (n / 1000).toFixed(1) + "K";
}
function fmtInt(n) {
return Math.round(n).toLocaleString("en-US");
}
function ns(tag) {
return document.createElementNS("http://www.w3.org/2000/svg", tag);
}
/* ------------------------------------------------------------------ */
/* Chart renderers (inline SVG, no libraries) */
/* ------------------------------------------------------------------ */
var COLORS = ["#5b5bf0", "#00b4a6", "#d98a2b", "#6c7393"];
function buildLine(data, opts) {
opts = opts || {};
var W = 320,
H = 120,
pad = 6;
var max = Math.max.apply(null, data) * 1.08;
var min = Math.min.apply(null, data) * 0.92;
var span = max - min || 1;
var step = (W - pad * 2) / (data.length - 1);
var pts = data.map((v, i) => {
var x = pad + i * step;
var y = H - pad - ((v - min) / span) * (H - pad * 2);
return [x, y];
});
var line = pts
.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1))
.join(" ");
var area =
"M" +
pad +
" " +
(H - pad) +
" " +
pts.map((p) => "L" + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ") +
" L" +
(W - pad) +
" " +
(H - pad) +
" Z";
var uid = "g" + Math.random().toString(36).slice(2, 7);
var svg = ns("svg");
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.setAttribute("preserveAspectRatio", "none");
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", "Line trend chart");
var defs = ns("defs");
var grad = ns("linearGradient");
grad.setAttribute("id", uid);
grad.setAttribute("x1", "0");
grad.setAttribute("y1", "0");
grad.setAttribute("x2", "0");
grad.setAttribute("y2", "1");
var s1 = ns("stop");
s1.setAttribute("offset", "0%");
s1.setAttribute("stop-color", "#5b5bf0");
s1.setAttribute("stop-opacity", "0.22");
var s2 = ns("stop");
s2.setAttribute("offset", "100%");
s2.setAttribute("stop-color", "#5b5bf0");
s2.setAttribute("stop-opacity", "0");
grad.appendChild(s1);
grad.appendChild(s2);
defs.appendChild(grad);
svg.appendChild(defs);
// gridlines
for (var g = 1; g < 4; g++) {
var gy = pad + ((H - pad * 2) / 4) * g;
var gl = ns("line");
gl.setAttribute("x1", pad);
gl.setAttribute("x2", W - pad);
gl.setAttribute("y1", gy);
gl.setAttribute("y2", gy);
gl.setAttribute("stroke", "rgba(16,19,34,0.07)");
gl.setAttribute("stroke-width", "1");
svg.appendChild(gl);
}
var fill = ns("path");
fill.setAttribute("d", area);
fill.setAttribute("fill", "url(#" + uid + ")");
svg.appendChild(fill);
var path = ns("path");
path.setAttribute("d", line);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "#5b5bf0");
path.setAttribute("stroke-width", "2.4");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.appendChild(path);
// end dot
var last = pts[pts.length - 1];
var dot = ns("circle");
dot.setAttribute("cx", last[0]);
dot.setAttribute("cy", last[1]);
dot.setAttribute("r", "3.2");
dot.setAttribute("fill", "#5b5bf0");
dot.setAttribute("stroke", "#fff");
dot.setAttribute("stroke-width", "1.6");
svg.appendChild(dot);
return svg;
}
function buildBars(data) {
var W = 320,
H = 120,
pad = 6,
gap = 10;
var max = Math.max.apply(null, data) * 1.05;
var bw = (W - pad * 2 - gap * (data.length - 1)) / data.length;
var svg = ns("svg");
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.setAttribute("preserveAspectRatio", "none");
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", "Bar chart by channel");
data.forEach((v, i) => {
var h = (v / max) * (H - pad * 2);
var x = pad + i * (bw + gap);
var y = H - pad - h;
var r = ns("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("fill", i === data.length - 1 ? "#00b4a6" : "#5b5bf0");
r.setAttribute("opacity", i === data.length - 1 ? "1" : (0.55 + 0.45 * (v / max)).toFixed(2));
svg.appendChild(r);
});
return svg;
}
function buildDonut(parts) {
var total = parts.reduce((a, p) => a + p.v, 0);
var wrap = document.createElement("div");
wrap.className = "donut-wrap";
var R = 42,
C = 50,
sw = 16,
circ = 2 * Math.PI * R;
var svg = ns("svg");
svg.setAttribute("viewBox", "0 0 100 100");
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", "Plan mix donut chart");
var track = ns("circle");
track.setAttribute("cx", C);
track.setAttribute("cy", C);
track.setAttribute("r", R);
track.setAttribute("fill", "none");
track.setAttribute("stroke", "rgba(16,19,34,0.07)");
track.setAttribute("stroke-width", sw);
svg.appendChild(track);
var offset = 0;
parts.forEach((p, i) => {
var frac = p.v / total;
var seg = ns("circle");
seg.setAttribute("cx", C);
seg.setAttribute("cy", C);
seg.setAttribute("r", R);
seg.setAttribute("fill", "none");
seg.setAttribute("stroke", COLORS[i % COLORS.length]);
seg.setAttribute("stroke-width", sw);
seg.setAttribute("stroke-dasharray", (frac * circ).toFixed(2) + " " + circ.toFixed(2));
seg.setAttribute("stroke-dashoffset", (-offset * circ).toFixed(2));
seg.setAttribute("transform", "rotate(-90 " + C + " " + C + ")");
seg.setAttribute("stroke-linecap", "butt");
svg.appendChild(seg);
offset += frac;
});
var tNum = ns("text");
tNum.setAttribute("x", C);
tNum.setAttribute("y", C - 1);
tNum.setAttribute("text-anchor", "middle");
tNum.setAttribute("font-size", "15");
tNum.setAttribute("font-weight", "800");
tNum.setAttribute("fill", "#101322");
tNum.textContent = fmtInt(total);
svg.appendChild(tNum);
var tLbl = ns("text");
tLbl.setAttribute("x", C);
tLbl.setAttribute("y", C + 12);
tLbl.setAttribute("text-anchor", "middle");
tLbl.setAttribute("font-size", "7");
tLbl.setAttribute("font-weight", "600");
tLbl.setAttribute("fill", "#6c7393");
tLbl.textContent = "accounts";
svg.appendChild(tLbl);
wrap.appendChild(svg);
var legend = document.createElement("ul");
legend.className = "legend";
parts.forEach((p, i) => {
var li = document.createElement("li");
var sw2 = document.createElement("span");
sw2.className = "swatch";
sw2.style.background = COLORS[i % COLORS.length];
var k = document.createElement("span");
k.className = "lk";
k.textContent = p.k;
var v = document.createElement("span");
v.className = "lv";
v.textContent = Math.round((p.v / total) * 100) + "%";
li.appendChild(sw2);
li.appendChild(k);
li.appendChild(v);
legend.appendChild(li);
});
wrap.appendChild(legend);
return wrap;
}
/* ------------------------------------------------------------------ */
/* Per-widget data model + drawing */
/* ------------------------------------------------------------------ */
function newSeries(n, lo, hi) {
var a = [];
for (var i = 0; i < n; i++) a.push(rand(lo, hi));
return a;
}
// seed each widget once, keyed off its chart type
function seed(slot) {
var type = slot.getAttribute("data-chart");
if (type === "line") {
return { type: type, data: newSeries(28, 140000, 192000) };
} else if (type === "bars") {
return { type: type, data: newSeries(4, 1800, 4600) };
} else {
return {
type: "donut",
parts: [
{ k: "Starter", v: 1480 },
{ k: "Team", v: 1120 },
{ k: "Business", v: 410 },
{ k: "Enterprise", v: 200 },
],
};
}
}
function drawSlot(slot, state) {
slot.textContent = "";
if (state.type === "line") slot.appendChild(buildLine(state.data));
else if (state.type === "bars") slot.appendChild(buildBars(state.data));
else slot.appendChild(buildDonut(state.parts));
}
/* Recompute headline value + delta from the series. */
function refreshFigure(widget, state) {
var valEl = widget.querySelector(".w-value");
var capEl = widget.querySelector(".w-cap");
if (!valEl) return;
var deltaEl = valEl.querySelector(".w-delta");
var current, prior, display;
if (state.type === "line") {
current = state.data[state.data.length - 1];
prior = state.data[Math.max(0, state.data.length - 8)];
display = fmtMoney(current);
if (capEl) capEl.textContent = "vs. " + fmtMoney(prior) + " prior period";
} else if (state.type === "bars") {
current = state.data.reduce((a, b) => a + b, 0);
prior = state.prior || current;
display = fmtInt(current);
if (capEl) capEl.textContent = "across " + state.data.length + " channels";
} else {
return; // donut has no headline value row
}
// rebuild value text node, preserve delta chip
valEl.childNodes[0]
? (valEl.childNodes[0].nodeValue = display)
: valEl.insertBefore(document.createTextNode(display), valEl.firstChild);
if (deltaEl) {
var pct = prior ? ((current - prior) / prior) * 100 : 0;
var up = pct >= 0;
deltaEl.className = "w-delta " + (up ? "is-up" : "is-down");
deltaEl.textContent = (up ? "▲ " : "▼ ") + Math.abs(pct).toFixed(1) + "%";
deltaEl.setAttribute(
"aria-label",
(up ? "up " : "down ") + Math.abs(pct).toFixed(1) + " percent"
);
}
}
/* ------------------------------------------------------------------ */
/* Init widgets */
/* ------------------------------------------------------------------ */
var widgets = Array.prototype.slice.call(document.querySelectorAll(".widget"));
widgets.forEach((widget) => {
var slot = widget.querySelector(".chart-slot");
if (!slot) return;
widget.__state = seed(slot);
drawSlot(slot, widget.__state);
refreshFigure(widget, widget.__state);
wireMenu(widget);
wireSizeToggle(widget);
wireDrag(widget);
});
/* ------------------------------------------------------------------ */
/* Refresh: spinner -> new data */
/* ------------------------------------------------------------------ */
function refreshWidget(widget, silent) {
var body = widget.querySelector(".w-body");
var slot = widget.querySelector(".chart-slot");
if (!body || !slot) return;
if (body.querySelector(".w-loading")) return; // already refreshing
var overlay = document.createElement("div");
overlay.className = "w-loading";
overlay.innerHTML = '<div class="spinner" role="status" aria-label="Loading"></div>';
body.appendChild(overlay);
setTimeout(() => {
var st = widget.__state;
if (st.type === "line") {
st.data = newSeries(28, 138000, 196000);
} else if (st.type === "bars") {
st.prior = st.data.reduce((a, b) => a + b, 0);
st.data = newSeries(4, 1700, 4800);
} else {
st.parts = st.parts.map((p) => ({ k: p.k, v: Math.round(p.v * rand(0.9, 1.12)) }));
}
drawSlot(slot, st);
refreshFigure(widget, st);
overlay.remove();
var stamp = widget.querySelector(".w-foot span:nth-child(2)");
if (stamp) stamp.textContent = "Synced just now";
if (!silent) toast("Widget refreshed");
}, 720);
}
/* ------------------------------------------------------------------ */
/* Menu popover + actions */
/* ------------------------------------------------------------------ */
var openMenu = null;
function closeMenus() {
if (!openMenu) return;
openMenu.menu.hidden = true;
openMenu.btn.setAttribute("aria-expanded", "false");
openMenu = null;
}
function wireMenu(widget) {
var btn = widget.querySelector(".w-menu-btn");
var menu = widget.querySelector(".menu");
if (!btn || !menu) return;
btn.addEventListener("click", (e) => {
e.stopPropagation();
var isOpen = !menu.hidden;
closeMenus();
if (!isOpen) {
menu.hidden = false;
btn.setAttribute("aria-expanded", "true");
openMenu = { menu: menu, btn: btn };
var first = menu.querySelector("[role=menuitem]");
if (first) first.focus();
}
});
menu.addEventListener("keydown", (e) => {
var items = Array.prototype.slice.call(menu.querySelectorAll("[role=menuitem]"));
var idx = items.indexOf(document.activeElement);
if (e.key === "ArrowDown") {
e.preventDefault();
items[(idx + 1) % items.length].focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
items[(idx - 1 + items.length) % items.length].focus();
} else if (e.key === "Escape") {
closeMenus();
btn.focus();
}
});
menu.querySelectorAll("[role=menuitem]").forEach((item) => {
item.addEventListener("click", () => {
var action = item.getAttribute("data-action");
closeMenus();
btn.focus();
if (action === "refresh") refreshWidget(widget);
else if (action === "expand") expandWidget(widget);
else if (action === "remove") removeWidget(widget);
});
});
}
document.addEventListener("click", closeMenus);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeMenus();
});
/* ------------------------------------------------------------------ */
/* Size toggle S / M / L */
/* ------------------------------------------------------------------ */
function wireSizeToggle(widget) {
var toggle = widget.querySelector(".size-toggle");
if (!toggle) return;
toggle.querySelectorAll("button").forEach((b) => {
b.addEventListener("click", () => {
setSize(widget, b.getAttribute("data-set-size"));
});
});
}
function setSize(widget, size) {
if (!size) return;
widget.classList.remove("size-s", "size-m", "size-l");
widget.classList.add("size-" + size);
widget.setAttribute("data-size", size);
widget.querySelectorAll(".size-toggle button").forEach((b) => {
b.setAttribute("aria-pressed", b.getAttribute("data-set-size") === size ? "true" : "false");
});
}
/* ------------------------------------------------------------------ */
/* Expand -> modal */
/* ------------------------------------------------------------------ */
var overlay = document.getElementById("overlay");
var modalBody = document.getElementById("modalBody");
var modalTitle = document.getElementById("modalTitle");
var modalClose = document.getElementById("modalClose");
var lastFocused = null;
function expandWidget(widget) {
if (!overlay || !modalBody) return;
lastFocused = document.activeElement;
var title = widget.querySelector(".w-title h2");
if (modalTitle) modalTitle.textContent = title ? title.textContent : "Widget detail";
modalBody.textContent = "";
var slot = document.createElement("div");
slot.className = "chart-slot";
modalBody.appendChild(slot);
drawSlot(slot, widget.__state);
// a small meta strip
var meta = document.createElement("dl");
meta.className = "modal-meta";
var stats = [
["Range", "Last 30 days"],
["Confidence", "98.2%"],
["Owner", "Rae Aldridge"],
["Sources", "5 connected"],
];
stats.forEach((s) => {
var d = document.createElement("div");
var dt = document.createElement("dt");
dt.textContent = s[0];
var dd = document.createElement("dd");
dd.textContent = s[1];
d.appendChild(dt);
d.appendChild(dd);
meta.appendChild(d);
});
modalBody.appendChild(meta);
overlay.hidden = false;
if (modalClose) modalClose.focus();
}
function closeModal() {
if (!overlay) return;
overlay.hidden = true;
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
if (modalClose) modalClose.addEventListener("click", closeModal);
if (overlay) {
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeModal();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && overlay && !overlay.hidden) closeModal();
});
/* ------------------------------------------------------------------ */
/* Remove with animation */
/* ------------------------------------------------------------------ */
function removeWidget(widget) {
widget.classList.add("is-removing");
setTimeout(() => {
widget.remove();
toast("Widget removed");
}, 220);
}
/* ------------------------------------------------------------------ */
/* Drag to rearrange */
/* ------------------------------------------------------------------ */
var board = document.getElementById("board");
var dragEl = null;
function wireDrag(widget) {
widget.addEventListener("dragstart", (e) => {
dragEl = widget;
widget.classList.add("is-dragging");
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
try {
e.dataTransfer.setData("text/plain", "w");
} catch (err) {}
}
});
widget.addEventListener("dragend", () => {
widget.classList.remove("is-dragging");
widgets.forEach((w) => {
w.classList.remove("drop-target");
});
dragEl = null;
});
widget.addEventListener("dragover", (e) => {
if (!dragEl || dragEl === widget) return;
e.preventDefault();
widget.classList.add("drop-target");
});
widget.addEventListener("dragleave", () => {
widget.classList.remove("drop-target");
});
widget.addEventListener("drop", (e) => {
e.preventDefault();
widget.classList.remove("drop-target");
if (!dragEl || dragEl === widget || !board) return;
var nodes = Array.prototype.slice.call(board.children);
var from = nodes.indexOf(dragEl);
var to = nodes.indexOf(widget);
if (from < to) board.insertBefore(dragEl, widget.nextSibling);
else board.insertBefore(dragEl, widget);
toast("Layout updated");
});
}
/* ------------------------------------------------------------------ */
/* Topbar: range select, add widget, mobile nav */
/* ------------------------------------------------------------------ */
var rangeSelect = document.getElementById("rangeSelect");
if (rangeSelect) {
rangeSelect.addEventListener("change", () => {
widgets.forEach((w) => {
refreshWidget(w, true);
});
var labels = { 7: "last 7 days", 30: "last 30 days", 90: "last quarter" };
toast("Showing " + (labels[rangeSelect.value] || "range"));
});
}
var addBtn = document.getElementById("addWidget");
if (addBtn) {
addBtn.addEventListener("click", () => {
toast("Widget catalog coming soon");
});
}
var navToggle = document.getElementById("navToggle");
var app = document.querySelector(".app");
if (navToggle && app) {
navToggle.addEventListener("click", () => {
app.classList.toggle("nav-open");
});
}
/* ------------------------------------------------------------------ */
/* Live ticking — nudge the line widget so it feels alive */
/* ------------------------------------------------------------------ */
var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
setInterval(() => {
var live = widgets.find
? widgets.find((w) => w.parentNode && w.__state && w.__state.type === "line")
: null;
if (!live) return;
var st = live.__state;
var last = st.data[st.data.length - 1];
st.data.push(Math.max(120000, last + rand(-4200, 5200)));
st.data.shift();
var slot = live.querySelector(".chart-slot");
if (slot) {
drawSlot(slot, st);
refreshFigure(live, st);
}
}, 4200);
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Widget — Resizable Widget Card Frame</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="app">
<!-- Sidebar -->
<nav class="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◆</span>
<span class="brand-name">Northwind <em>Ops</em></span>
</div>
<ul class="nav-list">
<li><a href="#" class="nav-item is-active" aria-current="page"><span class="nav-ico" aria-hidden="true">▦</span>Overview</a></li>
<li><a href="#" class="nav-item"><span class="nav-ico" aria-hidden="true">▤</span>Reports</a></li>
<li><a href="#" class="nav-item"><span class="nav-ico" aria-hidden="true">◷</span>Activity</a></li>
<li><a href="#" class="nav-item"><span class="nav-ico" aria-hidden="true">⚙</span>Settings</a></li>
</ul>
<div class="nav-foot">
<div class="avatar" aria-hidden="true">RA</div>
<div class="who">
<strong>Rae Aldridge</strong>
<span>Workspace admin</span>
</div>
</div>
</nav>
<!-- Main -->
<div class="main-col">
<header class="topbar">
<button class="icon-btn nav-toggle" id="navToggle" aria-label="Toggle navigation">☰</button>
<div class="head-text">
<h1>Dashboard widgets</h1>
<p>Reusable resizable widget frames · Q2 fiscal sandbox</p>
</div>
<div class="head-tools">
<label class="range" aria-label="Date range">
<span class="range-ico" aria-hidden="true">▾</span>
<select id="rangeSelect">
<option value="7">Last 7 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last quarter</option>
</select>
</label>
<button class="btn-primary" id="addWidget">+ Add widget</button>
</div>
</header>
<main class="board" id="board" aria-label="Widget board">
<!-- Widget 1 — Line chart -->
<article class="widget size-m" data-size="m" draggable="true" tabindex="0">
<div class="w-head">
<div class="w-title">
<h2>Net revenue</h2>
<p class="w-sub">Rolling 30-day inflow</p>
</div>
<div class="w-controls">
<div class="size-toggle" role="group" aria-label="Widget size">
<button type="button" data-set-size="s" aria-pressed="false">S</button>
<button type="button" data-set-size="m" aria-pressed="true">M</button>
<button type="button" data-set-size="l" aria-pressed="false">L</button>
</div>
<div class="menu-wrap">
<button class="icon-btn w-menu-btn" aria-haspopup="menu" aria-expanded="false" aria-label="Widget options">⋯</button>
<div class="menu" role="menu" hidden>
<button role="menuitem" data-action="refresh"><span aria-hidden="true">↻</span> Refresh</button>
<button role="menuitem" data-action="expand"><span aria-hidden="true">⤢</span> Expand</button>
<button role="menuitem" data-action="remove" class="is-danger"><span aria-hidden="true">✕</span> Remove</button>
</div>
</div>
</div>
</div>
<div class="w-body">
<div class="w-figure">
<div class="w-value">$184.2K<span class="w-delta is-up" aria-label="up 8.4 percent">▲ 8.4%</span></div>
<p class="w-cap">vs. $169.9K prior period</p>
</div>
<div class="chart-slot" data-chart="line"></div>
</div>
<footer class="w-foot">
<span class="dot dot-ok" aria-hidden="true"></span>
<span>Synced just now</span>
<span class="w-foot-r">5 sources</span>
</footer>
</article>
<!-- Widget 2 — Bars -->
<article class="widget size-s" data-size="s" draggable="true" tabindex="0">
<div class="w-head">
<div class="w-title">
<h2>Orders by channel</h2>
<p class="w-sub">Units shipped</p>
</div>
<div class="w-controls">
<div class="size-toggle" role="group" aria-label="Widget size">
<button type="button" data-set-size="s" aria-pressed="true">S</button>
<button type="button" data-set-size="m" aria-pressed="false">M</button>
<button type="button" data-set-size="l" aria-pressed="false">L</button>
</div>
<div class="menu-wrap">
<button class="icon-btn w-menu-btn" aria-haspopup="menu" aria-expanded="false" aria-label="Widget options">⋯</button>
<div class="menu" role="menu" hidden>
<button role="menuitem" data-action="refresh"><span aria-hidden="true">↻</span> Refresh</button>
<button role="menuitem" data-action="expand"><span aria-hidden="true">⤢</span> Expand</button>
<button role="menuitem" data-action="remove" class="is-danger"><span aria-hidden="true">✕</span> Remove</button>
</div>
</div>
</div>
</div>
<div class="w-body">
<div class="w-figure">
<div class="w-value">12,840<span class="w-delta is-down" aria-label="down 2.1 percent">▼ 2.1%</span></div>
<p class="w-cap">across 4 channels</p>
</div>
<div class="chart-slot" data-chart="bars"></div>
</div>
<footer class="w-foot">
<span class="dot dot-warn" aria-hidden="true"></span>
<span>Updated 4m ago</span>
<span class="w-foot-r">Hourly</span>
</footer>
</article>
<!-- Widget 3 — Donut -->
<article class="widget size-m" data-size="m" draggable="true" tabindex="0">
<div class="w-head">
<div class="w-title">
<h2>Plan mix</h2>
<p class="w-sub">Active subscriptions</p>
</div>
<div class="w-controls">
<div class="size-toggle" role="group" aria-label="Widget size">
<button type="button" data-set-size="s" aria-pressed="false">S</button>
<button type="button" data-set-size="m" aria-pressed="true">M</button>
<button type="button" data-set-size="l" aria-pressed="false">L</button>
</div>
<div class="menu-wrap">
<button class="icon-btn w-menu-btn" aria-haspopup="menu" aria-expanded="false" aria-label="Widget options">⋯</button>
<div class="menu" role="menu" hidden>
<button role="menuitem" data-action="refresh"><span aria-hidden="true">↻</span> Refresh</button>
<button role="menuitem" data-action="expand"><span aria-hidden="true">⤢</span> Expand</button>
<button role="menuitem" data-action="remove" class="is-danger"><span aria-hidden="true">✕</span> Remove</button>
</div>
</div>
</div>
</div>
<div class="w-body">
<div class="chart-slot" data-chart="donut"></div>
</div>
<footer class="w-foot">
<span class="dot dot-ok" aria-hidden="true"></span>
<span>3,210 accounts</span>
<span class="w-foot-r">Live</span>
</footer>
</article>
</main>
</div>
</div>
<!-- Expand overlay -->
<div class="overlay" id="overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<header class="modal-head">
<h2 id="modalTitle">Widget detail</h2>
<button class="icon-btn" id="modalClose" aria-label="Close detail">✕</button>
</header>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Resizable widget card frame
A drop-in widget shell for building dashboards. Every card shares the same anatomy — a header with title and subtitle, an S/M/L size toggle, a ⋯ overflow menu, a flexible body slot, and a status footer — so a board can mix metrics, bars, and donuts while staying visually consistent. The demo wires three live instances for the fictional Northwind Ops workspace: net revenue as an area-line trend, orders by channel as bars, and plan mix as a labelled donut, each rendered as inline SVG with no chart libraries.
The size toggle re-spans the card across the CSS grid (one column, two columns, or a taller two-column block) with a smooth transition, and the layout reflows to two columns, then a single column with off-canvas navigation as the viewport narrows. The overflow menu is a true popover: it traps arrow-key navigation, closes on Escape or an outside click, and restores focus to its trigger. Refresh drops a spinner over the body, then animates in fresh figures and a recolored delta chip; Expand lifts the chart into an accessible modal dialog with a meta strip; Remove fades the card out of the board.
Cards are draggable to rearrange, the page-level date range nudges every widget at once, and the revenue trend ticks on a timer so the board feels live (respecting prefers-reduced-motion). Controls are keyboard-operable with visible focus rings, regions use landmark roles, and the whole thing stays within a neutral product-UI palette and the project type scale.