Widget — Drag-rearrange widget grid
A polished analytics dashboard whose widget cards reorder by dragging a handle, with a live insertion placeholder, smooth reflow, and order persisted to localStorage. A full keyboard alternative moves any focused card with the arrow keys and announces each change through an aria-live region, while a Reset layout button restores the defaults. Cards carry KPI sparklines, an inline SVG bar chart, a revenue line chart, a donut, a top-pages list, and a live-ticking traffic tile.
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);
--sidebar-w: 244px;
}
* { box-sizing: border-box; }
html, body { height: 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 { margin: 0; }
a { color: inherit; text-decoration: none; }
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap; border: 0;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: var(--r-sm);
}
/* ---------- Layout ---------- */
.app {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
min-height: 100vh;
}
/* ---------- Sidebar ---------- */
.sidebar {
background: var(--white);
border-right: 1px solid var(--line);
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 22px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
font-size: 18px;
letter-spacing: -0.01em;
padding: 4px 6px;
}
.brand__mark {
display: grid;
place-items: center;
width: 30px; height: 30px;
border-radius: 9px;
background: linear-gradient(135deg, var(--brand), var(--brand-700));
color: #fff;
font-size: 14px;
}
.nav { 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 11px;
border-radius: var(--r-sm);
color: var(--ink-2);
font-weight: 500;
font-size: 14px;
transition: background 0.15s, color 0.15s;
}
.nav__item:hover { background: var(--bg); color: var(--ink); }
.nav__item.is-active { background: var(--brand-50); color: var(--brand-d); font-weight: 600; }
.nav__ico { width: 18px; text-align: center; opacity: 0.85; }
.sidebar__foot { margin-top: auto; }
.userchip {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: var(--r-md);
background: var(--bg);
}
.userchip__av {
display: grid; place-items: center;
width: 36px; height: 36px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
font-weight: 700; font-size: 13px;
}
.userchip__meta { display: flex; flex-direction: column; line-height: 1.2; }
.userchip__meta strong { font-size: 13.5px; }
.userchip__meta small { color: var(--muted); font-size: 12px; }
/* ---------- Main ---------- */
.main { padding: 22px 28px 40px; min-width: 0; }
.topbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 18px;
}
.topbar__title h1 { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; }
.topbar__sub { margin: 2px 0 0; color: var(--muted); font-size: 13px; }
.topbar__tools { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.nav-toggle { display: none; }
.iconbtn {
border: 1px solid var(--line);
background: var(--white);
color: var(--ink-2);
width: 36px; height: 36px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 16px;
display: grid; place-items: center;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.iconbtn:hover { background: var(--bg); border-color: var(--line-2); color: var(--ink); }
.btn {
border-radius: var(--r-sm);
font: inherit;
font-weight: 600;
font-size: 13.5px;
padding: 8px 14px;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn--ghost { background: var(--white); border-color: var(--line); color: var(--ink-2); }
.btn--ghost:hover { background: var(--bg); border-color: var(--line-2); color: var(--ink); }
.segmented {
display: inline-flex;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 3px;
gap: 2px;
}
.seg {
border: 0;
background: transparent;
font: inherit;
font-weight: 600;
font-size: 13px;
color: var(--muted);
padding: 5px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.seg:hover { color: var(--ink); }
.seg.is-active { background: var(--brand); color: #fff; box-shadow: var(--sh-1); }
/* ---------- Ribbon ---------- */
.ribbon {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 11px 16px;
margin-bottom: 18px;
box-shadow: var(--sh-1);
}
.ribbon__hint { margin: 0; color: var(--ink-2); font-size: 13px; }
.kbd {
display: inline-grid; place-items: center;
min-width: 20px; height: 20px;
padding: 0 5px;
border: 1px solid var(--line-2);
border-bottom-width: 2px;
border-radius: 5px;
background: var(--bg);
font-size: 11px;
font-weight: 600;
color: var(--ink-2);
vertical-align: middle;
}
.ribbon__status {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: var(--brand-d);
background: var(--brand-50);
padding: 4px 10px;
border-radius: 999px;
}
/* ---------- Grid ---------- */
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
align-items: start;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-1);
display: flex;
flex-direction: column;
min-width: 0;
transition: box-shadow 0.18s, transform 0.18s, border-color 0.18s, opacity 0.18s;
}
.card:hover { box-shadow: var(--sh-2); border-color: var(--line-2); }
.card:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; }
.card--wide { grid-column: span 2; }
.card.is-dragging {
opacity: 0.55;
box-shadow: var(--sh-2);
transform: scale(0.99);
cursor: grabbing;
}
.card.is-lifted {
box-shadow: 0 16px 32px rgba(16, 19, 34, 0.16);
border-color: var(--brand);
}
/* Insertion placeholder */
.placeholder {
border: 2px dashed var(--brand);
background: var(--brand-50);
border-radius: var(--r-md);
min-height: 120px;
}
.placeholder.placeholder--wide { grid-column: span 2; }
.card__head {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.card__title { font-size: 13.5px; font-weight: 600; color: var(--ink); flex: 1; min-width: 0; }
.handle {
cursor: grab;
color: var(--muted);
font-size: 15px;
line-height: 1;
letter-spacing: -2px;
padding: 2px 2px;
border-radius: 5px;
transition: color 0.15s, background 0.15s;
}
.handle:hover { color: var(--ink); background: var(--bg); }
.card__menu { width: 28px; height: 28px; font-size: 15px; border-color: transparent; background: transparent; }
.card__body { padding: 14px; }
/* KPI */
.kpi { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.kpi__value { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; }
.kpi__unit { font-size: 12px; color: var(--muted); font-weight: 500; }
.kpi__cap { margin: 2px 0 10px; font-size: 12px; color: var(--muted); }
.delta { font-size: 12px; font-weight: 700; }
.delta--up { color: var(--ok); }
.delta--down { color: var(--danger); }
/* Sparkline */
.spark { width: 100%; height: 36px; display: block; }
.spark__line { fill: none; stroke: var(--brand); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.spark__line--danger { stroke: var(--danger); }
.spark__line--accent { stroke: var(--accent); }
/* Bars */
.bars { width: 100%; height: auto; }
.bars__grid line { stroke: var(--line); stroke-width: 1; }
.bar { fill: var(--brand); transition: fill 0.15s; }
.bar:hover { fill: var(--brand-d); }
.bars__lbl { font-size: 9px; fill: var(--muted); text-anchor: middle; font-weight: 500; }
/* Line */
.line { width: 100%; height: auto; }
.line__area { fill: url(#fillGrad); }
.line__path { fill: none; stroke: var(--brand); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.line__dots circle { fill: var(--white); stroke: var(--brand); stroke-width: 2; }
/* Donut */
.card__body--donut { display: flex; align-items: center; gap: 18px; }
.donut { width: 108px; height: 108px; transform: rotate(-90deg); flex-shrink: 0; }
.donut__track { fill: none; stroke: var(--bg); stroke-width: 5; }
.donut__seg { fill: none; stroke-width: 5; stroke-linecap: round; }
.donut__seg--a { stroke: var(--brand); }
.donut__seg--b { stroke: var(--accent); }
.donut__seg--c { stroke: var(--line-2); }
.legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; font-size: 13px; }
.legend li { display: flex; align-items: center; gap: 8px; color: var(--ink-2); }
.legend b { margin-left: auto; color: var(--ink); }
.dot { width: 10px; height: 10px; border-radius: 3px; }
.dot--a { background: var(--brand); }
.dot--b { background: var(--accent); }
.dot--c { background: var(--line-2); }
/* Rows list */
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 11px; }
.row { display: grid; grid-template-columns: 92px 1fr auto; align-items: center; gap: 10px; font-size: 13px; }
.row__name { color: var(--ink-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row__bar { height: 7px; background: var(--bg); border-radius: 999px; overflow: hidden; }
.row__bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--brand), var(--accent)); border-radius: 999px; }
.row__val { font-weight: 700; font-size: 12.5px; }
/* Live pulse */
.pulse {
width: 9px; height: 9px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 0 rgba(0, 180, 166, 0.5);
animation: pulse 1.8s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(0, 180, 166, 0.45); }
70% { box-shadow: 0 0 0 7px rgba(0, 180, 166, 0); }
100% { box-shadow: 0 0 0 0 rgba(0, 180, 166, 0); }
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--ink);
color: #fff;
padding: 11px 18px;
border-radius: var(--r-sm);
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: 50;
}
.toast.is-show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ---------- Responsive ---------- */
@media (max-width: 1080px) {
.grid { grid-template-columns: repeat(2, 1fr); }
.card--wide { grid-column: span 2; }
.placeholder--wide { grid-column: span 2; }
}
@media (max-width: 720px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: fixed;
z-index: 40;
width: 80%;
max-width: 280px;
transform: translateX(-100%);
transition: transform 0.22s ease;
box-shadow: var(--sh-2);
}
.sidebar.is-open { transform: translateX(0); }
.nav-toggle { display: grid; }
.main { padding: 16px; }
.topbar__tools { gap: 8px; }
.grid { grid-template-columns: 1fr; gap: 14px; }
.card--wide, .placeholder--wide { grid-column: span 1; }
.ribbon__status { margin-left: 0; }
}
@media (max-width: 420px) {
.topbar { flex-wrap: wrap; }
.topbar__tools { margin-left: 0; width: 100%; }
.topbar__tools .btn--ghost { margin-left: auto; }
.kpi__value { font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}/* Northbeam Analytics — Drag-rearrange widget grid
* Vanilla JS only. Pointer + HTML5 drag, keyboard reorder, localStorage persistence.
*/
(function () {
"use strict";
var STORAGE_KEY = "northbeam.widget.order.v1";
var grid = document.getElementById("grid");
var resetBtn = document.getElementById("resetBtn");
var navToggle = document.getElementById("navToggle");
var sidebar = document.querySelector(".sidebar");
var liveRegion = document.getElementById("liveRegion");
var layoutStatus = document.getElementById("layoutStatus");
var toastEl = document.getElementById("toast");
if (!grid) return;
var cards = Array.prototype.slice.call(grid.querySelectorAll(".card"));
var defaultOrder = cards.map(function (c) { return c.dataset.id; });
/* ---------- Toast helper ---------- */
var toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-show");
}, 2200);
}
/* ---------- aria-live announcer ---------- */
function announce(msg) {
if (!liveRegion) return;
liveRegion.textContent = "";
// force re-announce even if text repeats
window.requestAnimationFrame(function () {
liveRegion.textContent = msg;
});
}
/* ---------- Helper: a card's title for messages ---------- */
function titleOf(card) {
var t = card.querySelector(".card__title");
return t ? t.textContent.trim() : (card.dataset.id || "widget");
}
function positionLabel(card) {
var list = currentCards();
var idx = list.indexOf(card);
return (idx + 1) + " of " + list.length;
}
function currentCards() {
return Array.prototype.slice.call(grid.querySelectorAll(".card"));
}
/* ---------- Persistence ---------- */
function saveOrder() {
var order = currentCards().map(function (c) { return c.dataset.id; });
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
} catch (e) { /* storage unavailable — ignore */ }
updateStatus(order);
}
function isDefault(order) {
if (order.length !== defaultOrder.length) return false;
for (var i = 0; i < order.length; i++) {
if (order[i] !== defaultOrder[i]) return false;
}
return true;
}
function updateStatus(order) {
if (!layoutStatus) return;
layoutStatus.textContent = isDefault(order) ? "Default layout" : "Custom layout";
}
function applyOrder(order) {
if (!order || !order.length) return;
order.forEach(function (id) {
var card = grid.querySelector('.card[data-id="' + id + '"]');
if (card) grid.appendChild(card);
});
updateStatus(order);
}
function loadOrder() {
var saved;
try {
saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
} catch (e) { saved = null; }
if (Array.isArray(saved) && saved.length) {
applyOrder(saved);
} else {
updateStatus(defaultOrder);
}
}
/* ---------- Pointer / HTML5 drag-and-drop ---------- */
var dragSrc = null;
var placeholder = null;
function makePlaceholder(refCard) {
var ph = document.createElement("div");
ph.className = "placeholder";
if (refCard.classList.contains("card--wide")) ph.classList.add("placeholder--wide");
ph.setAttribute("aria-hidden", "true");
return ph;
}
grid.addEventListener("dragstart", function (e) {
var card = e.target.closest && e.target.closest(".card");
if (!card) return;
dragSrc = card;
card.classList.add("is-dragging");
try {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", card.dataset.id);
} catch (err) { /* some browsers restrict — fine */ }
placeholder = makePlaceholder(card);
// Insert placeholder where the card is, then hide the card from flow visually.
grid.insertBefore(placeholder, card.nextSibling);
});
grid.addEventListener("dragend", function () {
if (dragSrc) dragSrc.classList.remove("is-dragging");
if (placeholder && placeholder.parentNode) {
placeholder.parentNode.replaceChild(dragSrc, placeholder);
}
cleanupDrag();
saveOrder();
if (dragSrc) {
announce(titleOf(dragSrc) + " dropped, position " + positionLabel(dragSrc));
}
dragSrc = null;
});
function cleanupDrag() {
placeholder = null;
}
// Determine the card we are hovering and whether to drop before/after it.
grid.addEventListener("dragover", function (e) {
if (!dragSrc) return;
e.preventDefault();
try { e.dataTransfer.dropEffect = "move"; } catch (err) {}
var over = e.target.closest && e.target.closest(".card");
if (!over || over === dragSrc || !placeholder) return;
var rect = over.getBoundingClientRect();
// Use both axes to feel natural in a 2D grid.
var horizontal = rect.width >= rect.height;
var beforeMid = horizontal
? (e.clientX < rect.left + rect.width / 2)
: (e.clientY < rect.top + rect.height / 2);
if (beforeMid) {
grid.insertBefore(placeholder, over);
} else {
grid.insertBefore(placeholder, over.nextSibling);
}
});
grid.addEventListener("drop", function (e) {
if (!dragSrc) return;
e.preventDefault();
});
/* ---------- Keyboard reorder ---------- */
// Focus a card, use arrows to move. Enter/Space optional "grab" hint.
grid.addEventListener("keydown", function (e) {
var card = e.target.closest && e.target.closest(".card");
if (!card || card !== e.target) return; // only when the card itself is focused
var keys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"];
if (keys.indexOf(e.key) === -1) return;
e.preventDefault();
var list = currentCards();
var idx = list.indexOf(card);
var moved = false;
if ((e.key === "ArrowLeft" || e.key === "ArrowUp") && idx > 0) {
grid.insertBefore(card, list[idx - 1]);
moved = true;
} else if ((e.key === "ArrowRight" || e.key === "ArrowDown") && idx < list.length - 1) {
grid.insertBefore(card, list[idx + 1].nextSibling);
moved = true;
} else if (e.key === "Home" && idx > 0) {
grid.insertBefore(card, list[0]);
moved = true;
} else if (e.key === "End" && idx < list.length - 1) {
grid.appendChild(card);
moved = true;
}
if (moved) {
card.focus();
saveOrder();
announce(titleOf(card) + " moved to position " + positionLabel(card));
} else {
announce(titleOf(card) + " is at the " + (idx === 0 ? "start" : "end") + " of the grid");
}
});
// Subtle "lifted" affordance while a card is focused.
grid.addEventListener("focusin", function (e) {
var card = e.target.closest && e.target.closest(".card");
if (card && card === e.target) card.classList.add("is-lifted");
});
grid.addEventListener("focusout", function (e) {
var card = e.target.closest && e.target.closest(".card");
if (card) card.classList.remove("is-lifted");
});
/* ---------- Reset layout ---------- */
if (resetBtn) {
resetBtn.addEventListener("click", function () {
applyOrder(defaultOrder);
try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
updateStatus(defaultOrder);
announce("Layout reset to default");
toast("Layout reset to default");
});
}
/* ---------- Widget menu buttons ---------- */
grid.addEventListener("click", function (e) {
var menu = e.target.closest && e.target.closest(".card__menu");
if (!menu) return;
var card = menu.closest(".card");
toast(titleOf(card) + " · menu (demo)");
});
/* ---------- Date-range segmented control ---------- */
var segs = Array.prototype.slice.call(document.querySelectorAll(".seg"));
// Realistic-but-fictional values keyed by range.
var DATASETS = {
"7d": { users: "11,940", mrr: "$179.8k", churn: "2.1%", deltas: ["▲ 2.7%", "▲ 0.6%", "▼ 0.2%"] },
"30d": { users: "48,210", mrr: "$182.4k", churn: "2.3%", deltas: ["▲ 8.4%", "▲ 3.1%", "▼ 0.4%"] },
"90d": { users: "131,505", mrr: "$176.2k", churn: "2.8%", deltas: ["▲ 19.2%", "▲ 6.5%", "▲ 0.3%"] }
};
function setKpi(name, value) {
var el = grid.querySelector('[data-kpi="' + name + '"]');
if (el) el.textContent = value;
}
function applyRange(range) {
var d = DATASETS[range];
if (!d) return;
setKpi("users", d.users);
setKpi("mrr", d.mrr);
setKpi("churn", d.churn);
// Update KPI deltas (active users, mrr, churn order).
var deltaEls = grid.querySelectorAll(".card--kpi .delta");
for (var i = 0; i < deltaEls.length && i < d.deltas.length; i++) {
var txt = d.deltas[i];
deltaEls[i].textContent = txt;
var up = txt.indexOf("▲") === 0;
// Churn is "lower is better": an up arrow there is bad.
var isChurn = i === 2;
var good = isChurn ? !up : up;
deltaEls[i].classList.toggle("delta--up", good);
deltaEls[i].classList.toggle("delta--down", !good);
}
}
segs.forEach(function (seg) {
seg.addEventListener("click", function () {
segs.forEach(function (s) {
s.classList.remove("is-active");
s.removeAttribute("aria-selected");
});
seg.classList.add("is-active");
seg.setAttribute("aria-selected", "true");
applyRange(seg.dataset.range);
toast("Range: last " + seg.dataset.range);
});
});
/* ---------- Live traffic tick ---------- */
var liveEl = grid.querySelector('[data-live="req"]');
var liveSpark = document.getElementById("liveSpark");
var liveVal = 1284;
var sparkData = [20, 16, 22, 14, 18, 12, 16, 10, 14];
function fmt(n) { return n.toLocaleString("en-US"); }
function tickLive() {
// Random walk within a plausible band.
var delta = Math.round((Math.random() - 0.45) * 120);
liveVal = Math.max(820, Math.min(1980, liveVal + delta));
if (liveEl) liveEl.textContent = fmt(liveVal);
// Shift sparkline: map value into 4..30 range (inverted Y for SVG).
sparkData.shift();
var norm = 30 - Math.round(((liveVal - 820) / (1980 - 820)) * 24);
sparkData.push(Math.max(4, Math.min(30, norm)));
if (liveSpark) {
var pts = sparkData.map(function (y, i) {
return (i * (120 / (sparkData.length - 1))).toFixed(0) + "," + y;
});
liveSpark.setAttribute("points", pts.join(" "));
}
}
var liveTimer = setInterval(tickLive, 2400);
document.addEventListener("visibilitychange", function () {
if (document.hidden) {
clearInterval(liveTimer);
} else {
liveTimer = setInterval(tickLive, 2400);
}
});
/* ---------- Off-canvas nav (mobile) ---------- */
if (navToggle && sidebar) {
navToggle.addEventListener("click", function () {
var open = sidebar.classList.toggle("is-open");
navToggle.setAttribute("aria-expanded", String(open));
});
document.addEventListener("click", function (e) {
if (
sidebar.classList.contains("is-open") &&
!sidebar.contains(e.target) &&
e.target !== navToggle
) {
sidebar.classList.remove("is-open");
navToggle.setAttribute("aria-expanded", "false");
}
});
}
/* ---------- Init ---------- */
loadOrder();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northbeam Analytics — Drag-rearrange widget grid</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">Northbeam</span>
</div>
<ul class="nav">
<li><a class="nav__item is-active" href="#" aria-current="page"><span class="nav__ico" aria-hidden="true">▦</span> Overview</a></li>
<li><a class="nav__item" href="#"><span class="nav__ico" aria-hidden="true">↗</span> Acquisition</a></li>
<li><a class="nav__item" href="#"><span class="nav__ico" aria-hidden="true">◷</span> Retention</a></li>
<li><a class="nav__item" href="#"><span class="nav__ico" aria-hidden="true">▣</span> Revenue</a></li>
<li><a class="nav__item" href="#"><span class="nav__ico" aria-hidden="true">⚙</span> Settings</a></li>
</ul>
<div class="sidebar__foot">
<div class="userchip">
<span class="userchip__av" aria-hidden="true">AR</span>
<span class="userchip__meta">
<strong>Avery Rourke</strong>
<small>Growth lead</small>
</span>
</div>
</div>
</nav>
<!-- Main -->
<main class="main" id="main">
<header class="topbar">
<button class="iconbtn nav-toggle" id="navToggle" aria-label="Toggle navigation" aria-expanded="false">☰</button>
<div class="topbar__title">
<h1>Overview</h1>
<p class="topbar__sub">Northbeam Analytics · fictional growth dashboard</p>
</div>
<div class="topbar__tools">
<div class="segmented" role="tablist" aria-label="Date range">
<button role="tab" class="seg" data-range="7d">7d</button>
<button role="tab" class="seg is-active" data-range="30d" aria-selected="true">30d</button>
<button role="tab" class="seg" data-range="90d">90d</button>
</div>
<button class="btn btn--ghost" id="resetBtn" type="button">↺ Reset layout</button>
</div>
</header>
<div class="ribbon">
<p class="ribbon__hint" id="dragHint">
Drag a card by its handle <span class="kbd" aria-hidden="true">⠿</span> to rearrange — or focus a card and use
<span class="kbd">←</span><span class="kbd">→</span><span class="kbd">↑</span><span class="kbd">↓</span> to move it. Order is saved automatically.
</p>
<span class="ribbon__status" id="layoutStatus">Default layout</span>
</div>
<!-- Live region for keyboard reorder announcements -->
<p class="visually-hidden" role="status" aria-live="polite" id="liveRegion"></p>
<!-- Draggable widget grid -->
<section class="grid" id="grid" aria-label="Dashboard widgets. Reorderable.">
<!-- KPI: Active users -->
<article class="card card--kpi" draggable="true" tabindex="0" role="group"
data-id="active-users" aria-roledescription="Reorderable widget" aria-label="Active users widget">
<header class="card__head">
<span class="handle" aria-hidden="true" title="Drag to move">⠿</span>
<h2 class="card__title">Active users</h2>
<button class="iconbtn card__menu" type="button" aria-label="Widget menu">⋯</button>
</header>
<div class="card__body">
<div class="kpi">
<strong class="kpi__value" data-kpi="users">48,210</strong>
<span class="delta delta--up">▲ 8.4%</span>
</div>
<p class="kpi__cap">vs previous period</p>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true">
<polyline class="spark__line" points="0,28 15,24 30,26 45,18 60,20 75,12 90,14 105,8 120,6" />
</svg>
</div>
</article>
<!-- KPI: MRR -->
<article class="card card--kpi" draggable="true" tabindex="0" role="group"
data-id="mrr" aria-roledescription="Reorderable widget" aria-label="Monthly recurring revenue widget">
<header class="card__head">
<span class="handle" aria-hidden="true" title="Drag to move">⠿</span>
<h2 class="card__title">MRR</h2>
<button class="iconbtn card__menu" type="button" aria-label="Widget menu">⋯</button>
</header>
<div class="card__body">
<div class="kpi">
<strong class="kpi__value" data-kpi="mrr">$182.4k</strong>
<span class="delta delta--up">▲ 3.1%</span>
</div>
<p class="kpi__cap">recurring monthly revenue</p>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true">
<polyline class="spark__line" points="0,30 15,27 30,24 45,25 60,20 75,18 90,16 105,12 120,10" />
</svg>
</div>
</article>
<!-- KPI: Churn -->
<article class="card card--kpi" draggable="true" tabindex="0" role="group"
data-id="churn" aria-roledescription="Reorderable widget" aria-label="Churn rate widget">
<header class="card__head">
<span class="handle" aria-hidden="true" title="Drag to move">⠿</span>
<h2 class="card__title">Churn</h2>
<button class="iconbtn card__menu" type="button" aria-label="Widget menu">⋯</button>
</header>
<div class="card__body">
<div class="kpi">
<strong class="kpi__value" data-kpi="churn">2.3%</strong>
<span class="delta delta--down">▼ 0.4%</span>
</div>
<p class="kpi__cap">lower is better</p>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true">
<polyline class="spark__line spark__line--danger" points="0,10 15,12 30,11 45,14 60,16 75,15 90,18 105,20 120,22" />
</svg>
</div>
</article>
<!-- Bar chart: Signups by channel -->
<article class="card card--wide" draggable="true" tabindex="0" role="group"
data-id="signups" aria-roledescription="Reorderable widget" aria-label="Signups by channel chart widget">
<header class="card__head">
<span class="handle" aria-hidden="true" title="Drag to move">⠿</span>
<h2 class="card__title">Signups by channel</h2>
<button class="iconbtn card__menu" type="button" aria-label="Widget menu">⋯</button>
</header>
<div class="card__body">
<svg class="bars" viewBox="0 0 320 150" role="img" aria-label="Bar chart of signups by acquisition channel">
<g class="bars__grid" aria-hidden="true">
<line x1="40" y1="20" x2="320" y2="20" /><line x1="40" y1="55" x2="320" y2="55" />
<line x1="40" y1="90" x2="320" y2="90" /><line x1="40" y1="125" x2="320" y2="125" />
</g>
<g class="bars__set">
<rect class="bar" x="52" y="48" width="34" height="77" rx="4" /><text class="bars__lbl" x="69" y="142">Organic</text>
<rect class="bar" x="116" y="30" width="34" height="95" rx="4" /><text class="bars__lbl" x="133" y="142">Paid</text>
<rect class="bar" x="180" y="72" width="34" height="53" rx="4" /><text class="bars__lbl" x="197" y="142">Referral</text>
<rect class="bar" x="244" y="92" width="34" height="33" rx="4" /><text class="bars__lbl" x="261" y="142">Social</text>
</g>
</svg>
</div>
</article>
<!-- Line chart: Revenue trend -->
<article class="card card--wide" draggable="true" tabindex="0" role="group"
data-id="trend" aria-roledescription="Reorderable widget" aria-label="Revenue trend chart widget">
<header class="card__head">
<span class="handle" aria-hidden="true" title="Drag to move">⠿</span>
<h2 class="card__title">Revenue trend</h2>
<button class="iconbtn card__menu" type="button" aria-label="Widget menu">⋯</button>
</header>
<div class="card__body">
<svg class="line" viewBox="0 0 320 150" role="img" aria-label="Line chart of revenue trend over the period">
<defs>
<linearGradient id="fillGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--brand)" stop-opacity="0.22" />
<stop offset="100%" stop-color="var(--brand)" stop-opacity="0" />
</linearGradient>
</defs>
<polygon class="line__area" points="20,118 70,96 120,104 170,72 220,80 270,48 300,40 300,134 20,134" />
<polyline class="line__path" points="20,118 70,96 120,104 170,72 220,80 270,48 300,40" />
<g class="line__dots">
<circle cx="20" cy="118" r="3" /><circle cx="70" cy="96" r="3" /><circle cx="120" cy="104" r="3" />
<circle cx="170" cy="72" r="3" /><circle cx="220" cy="80" r="3" /><circle cx="270" cy="48" r="3" /><circle cx="300" cy="40" r="3" />
</g>
</svg>
</div>
</article>
<!-- Donut: Plan mix -->
<article class="card" draggable="true" tabindex="0" role="group"
data-id="plans" aria-roledescription="Reorderable widget" aria-label="Plan mix donut chart widget">
<header class="card__head">
<span class="handle" aria-hidden="true" title="Drag to move">⠿</span>
<h2 class="card__title">Plan mix</h2>
<button class="iconbtn card__menu" type="button" aria-label="Widget menu">⋯</button>
</header>
<div class="card__body card__body--donut">
<svg class="donut" viewBox="0 0 42 42" role="img" aria-label="Donut chart of subscription plan mix">
<circle class="donut__track" cx="21" cy="21" r="15.915" />
<circle class="donut__seg donut__seg--a" cx="21" cy="21" r="15.915" stroke-dasharray="52 48" stroke-dashoffset="25" />
<circle class="donut__seg donut__seg--b" cx="21" cy="21" r="15.915" stroke-dasharray="31 69" stroke-dashoffset="73" />
<circle class="donut__seg donut__seg--c" cx="21" cy="21" r="15.915" stroke-dasharray="17 83" stroke-dashoffset="42" />
</svg>
<ul class="legend">
<li><span class="dot dot--a"></span>Pro <b>52%</b></li>
<li><span class="dot dot--b"></span>Team <b>31%</b></li>
<li><span class="dot dot--c"></span>Free <b>17%</b></li>
</ul>
</div>
</article>
<!-- Top pages list -->
<article class="card" draggable="true" tabindex="0" role="group"
data-id="pages" aria-roledescription="Reorderable widget" aria-label="Top pages widget">
<header class="card__head">
<span class="handle" aria-hidden="true" title="Drag to move">⠿</span>
<h2 class="card__title">Top pages</h2>
<button class="iconbtn card__menu" type="button" aria-label="Widget menu">⋯</button>
</header>
<div class="card__body">
<ul class="rows">
<li class="row"><span class="row__name">/pricing</span><span class="row__bar"><i style="width:92%"></i></span><b class="row__val">14.2k</b></li>
<li class="row"><span class="row__name">/features</span><span class="row__bar"><i style="width:71%"></i></span><b class="row__val">11.0k</b></li>
<li class="row"><span class="row__name">/blog/launch</span><span class="row__bar"><i style="width:58%"></i></span><b class="row__val">9.0k</b></li>
<li class="row"><span class="row__name">/docs</span><span class="row__bar"><i style="width:40%"></i></span><b class="row__val">6.2k</b></li>
</ul>
</div>
</article>
<!-- Live tile: requests/min -->
<article class="card" draggable="true" tabindex="0" role="group"
data-id="live" aria-roledescription="Reorderable widget" aria-label="Live requests widget">
<header class="card__head">
<span class="handle" aria-hidden="true" title="Drag to move">⠿</span>
<h2 class="card__title">Live traffic</h2>
<span class="pulse" aria-hidden="true"></span>
</header>
<div class="card__body">
<div class="kpi">
<strong class="kpi__value" data-live="req">1,284</strong>
<span class="kpi__unit">req / min</span>
</div>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true">
<polyline class="spark__line spark__line--accent" id="liveSpark" points="0,20 15,16 30,22 45,14 60,18 75,12 90,16 105,10 120,14" />
</svg>
</div>
</article>
</section>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Drag-rearrange widget grid
A self-contained “Northbeam Analytics” dashboard built around a reorderable widget grid. Each card exposes a grab handle, a header menu, and a small data visualization — KPI sparklines with up/down deltas, an inline SVG bar chart of signups by channel, a revenue line chart with a gradient area fill, a plan-mix donut, a ranked top-pages list, and a live traffic tile that ticks every couple of seconds. The shell uses a sidebar nav, a page header with a date-range segmented control, and a soft neutral product palette.
Dragging a card by its handle lifts it, shows a dashed insertion placeholder where it will land, and
reflows the rest of the grid smoothly; dropping persists the new order to localStorage so it
survives reloads. The whole grid is keyboard-operable: focus any card and press the arrow keys (or
Home/End) to move it, with each move announced through a polite aria-live region for screen
readers. A “Reset layout” button clears the saved order and restores the defaults.
Filters and live data are wired up too — switching the 7d / 30d / 90d range updates the KPI values
and recolors their deltas (churn is treated as “lower is better”), the live tile random-walks within
a plausible band and redraws its sparkline, and the sidebar collapses to an off-canvas drawer below
720px. Everything is vanilla JS with no libraries, and motion respects prefers-reduced-motion.