Dashboard — Mobile-first layout
A mobile-first finance dashboard framed inside a centered phone shell — sticky greeting bar, a gradient hero balance card with delta chip and inline-SVG sparkline, a horizontally swipeable row of snap-scrolling KPI chips, and stacked widget cards holding a mini line chart, a transactions list, and a budget donut. A fixed bottom tab bar swaps Home, Stats, Activity, and Profile views, while a refresh button and pull-down gesture re-roll the data with animated count-ups. Pure HTML, CSS, and vanilla JavaScript.
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);
--sh-3: 0 18px 44px rgba(16, 19, 34, 0.16);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
p,
ul,
figure {
margin: 0;
}
ul {
padding: 0;
list-style: none;
}
button {
font-family: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
/* ============ STAGE / PHONE FRAME ============ */
.stage {
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 28px 16px;
background:
radial-gradient(120% 80% at 50% -10%, #eef0ff 0%, var(--bg) 55%),
var(--bg);
}
.phone {
position: relative;
width: 100%;
max-width: 420px;
height: min(860px, calc(100dvh - 56px));
background: var(--bg);
border-radius: 38px;
box-shadow: var(--sh-3);
border: 1px solid var(--line);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ============ TOP BAR ============ */
.topbar {
position: sticky;
top: 0;
z-index: 6;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 12px;
background: color-mix(in srgb, var(--white) 86%, transparent);
backdrop-filter: saturate(160%) blur(12px);
border-bottom: 1px solid var(--line);
}
.topbar__lead {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
font-size: 14px;
color: var(--white);
background: linear-gradient(135deg, var(--brand), var(--brand-700));
flex: none;
}
.avatar--lg {
width: 72px;
height: 72px;
font-size: 24px;
margin: 4px auto 6px;
}
.topbar__hello {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.topbar__greet {
font-size: 12px;
color: var(--muted);
}
.topbar__name {
font-weight: 700;
font-size: 15px;
}
.topbar__actions {
display: flex;
gap: 8px;
}
.iconbtn {
position: relative;
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--white);
color: var(--ink-2);
display: grid;
place-items: center;
transition: background 0.15s, transform 0.15s, color 0.15s;
}
.iconbtn:hover {
background: var(--brand-50);
color: var(--brand-d);
}
.iconbtn:active {
transform: scale(0.94);
}
.iconbtn--badge .dot {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--danger);
color: var(--white);
font-size: 10px;
font-weight: 700;
display: grid;
place-items: center;
border: 2px solid var(--white);
}
.is-spinning svg {
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ============ PULL TO REFRESH ============ */
.ptr {
height: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 600;
transition: height 0.25s ease;
background: var(--bg);
}
.ptr.is-on {
height: 38px;
}
.ptr__spin {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--line-2);
border-top-color: var(--brand);
animation: spin 0.7s linear infinite;
}
/* ============ SCROLLER + VIEWS ============ */
.scroller {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding: 16px 16px 96px;
scroll-behavior: smooth;
}
.view {
display: flex;
flex-direction: column;
gap: 14px;
animation: fade 0.3s ease;
}
.view[hidden] {
display: none;
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(8px);
}
}
/* ============ HERO ============ */
.hero {
position: relative;
border-radius: var(--r-lg);
padding: 18px 18px 0;
color: var(--white);
background:
radial-gradient(120% 120% at 100% 0%, var(--brand) 0%, var(--brand-700) 70%);
box-shadow: var(--sh-2);
overflow: hidden;
}
.hero__top {
display: flex;
align-items: center;
justify-content: space-between;
}
.hero__label {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.82);
letter-spacing: 0.02em;
}
.hero__value {
margin-top: 6px;
font-size: 38px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.05;
font-variant-numeric: tabular-nums;
}
.hero__cur {
font-size: 24px;
font-weight: 700;
vertical-align: top;
margin-right: 2px;
opacity: 0.85;
}
.hero__sub {
margin-top: 4px;
font-size: 12.5px;
color: rgba(255, 255, 255, 0.82);
}
.hero__spark {
display: block;
width: calc(100% + 36px);
height: 56px;
margin: 8px -18px 0;
}
/* chips inside hero use white scheme */
.hero .chip--up {
background: rgba(255, 255, 255, 0.2);
color: var(--white);
}
/* ============ CHIPS / DELTAS ============ */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.chip--up {
color: var(--ok);
background: rgba(47, 158, 111, 0.12);
}
.chip--down {
color: var(--danger);
background: rgba(212, 80, 62, 0.12);
}
.chip--sm {
padding: 2px 7px;
font-size: 11px;
}
/* ============ CHIP RAIL (swipeable) ============ */
.chiprail {
display: flex;
gap: 10px;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding: 2px 2px 8px;
margin: 0 -2px;
scrollbar-width: none;
}
.chiprail::-webkit-scrollbar {
display: none;
}
.kpi {
scroll-snap-align: start;
flex: 0 0 auto;
width: 142px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px;
box-shadow: var(--sh-1);
transition: transform 0.15s, box-shadow 0.15s;
}
.kpi:active {
transform: scale(0.97);
}
.kpi__top {
display: flex;
align-items: center;
justify-content: space-between;
}
.kpi__label {
font-size: 11.5px;
color: var(--muted);
font-weight: 600;
}
.kpi__val {
margin-top: 6px;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.kpi__spark {
display: block;
width: 100%;
height: 26px;
margin-top: 6px;
}
/* ============ CARDS ============ */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
overflow: hidden;
}
.card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 8px;
}
.card__title {
font-size: 15px;
font-weight: 700;
}
.card__meta {
font-size: 12px;
color: var(--muted);
}
.menu {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
font-size: 18px;
line-height: 1;
flex: none;
transition: background 0.15s, color 0.15s;
}
.menu:hover {
background: var(--bg);
color: var(--ink);
}
.card__body {
padding: 0 16px 16px;
}
/* ============ MINI LINE CHART ============ */
.trend {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
}
.trend__val {
font-size: 22px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.linechart {
display: block;
width: 100%;
height: 120px;
}
.linechart .gl {
stroke: var(--line);
stroke-width: 1;
stroke-dasharray: 2 4;
}
#spendArea,
#spendLine,
#spendDot {
transition: d 0.5s ease;
}
.dows {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-top: 6px;
font-size: 11px;
color: var(--muted);
text-align: center;
}
.dows--mo {
grid-template-columns: repeat(6, 1fr);
}
/* ============ TX LIST ============ */
.txlist {
padding: 0 8px 8px;
}
.txlist li {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 8px;
border-radius: var(--r-sm);
min-height: 44px;
}
.txlist li + li {
border-top: 1px solid var(--line);
}
.txico {
width: 38px;
height: 38px;
border-radius: 11px;
display: grid;
place-items: center;
font-size: 17px;
flex: none;
background: var(--brand-50);
}
.txmain {
flex: 1;
min-width: 0;
}
.txname {
font-weight: 600;
font-size: 13.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txcat {
font-size: 11.5px;
color: var(--muted);
}
.txamt {
font-weight: 700;
font-variant-numeric: tabular-nums;
font-size: 14px;
}
.txamt.is-in {
color: var(--ok);
}
/* ============ DONUT ============ */
.donutrow {
display: flex;
align-items: center;
gap: 16px;
}
.donut {
width: 120px;
height: 120px;
flex: none;
transform: rotate(-90deg);
}
.donut__track {
fill: none;
stroke: var(--line);
stroke-width: 14;
}
.donut__seg {
fill: none;
stroke-width: 14;
stroke-linecap: round;
transition: stroke-dasharray 0.6s ease, stroke-dashoffset 0.6s ease;
}
.donut__big,
.donut__cap {
transform: rotate(90deg);
transform-origin: 60px 60px;
text-anchor: middle;
fill: var(--ink);
}
.donut__big {
font-size: 22px;
font-weight: 800;
}
.donut__cap {
font-size: 11px;
fill: var(--muted);
}
.legend {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.legend li {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.legend i {
width: 10px;
height: 10px;
border-radius: 3px;
flex: none;
}
.legend b {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.legend .lname {
color: var(--ink-2);
}
/* ============ BAR CHART (stats) ============ */
.barchart {
display: block;
width: 100%;
height: 150px;
}
.bar {
transition: height 0.5s ease, y 0.5s ease;
}
.bar--in {
fill: var(--brand);
}
.bar--out {
fill: var(--accent);
}
.barlegend,
.dows--mo {
margin-top: 8px;
}
.barlegend {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--ink-2);
}
.barlegend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.sw {
width: 10px;
height: 10px;
border-radius: 3px;
}
.sw--in {
background: var(--brand);
}
.sw--out {
background: var(--accent);
}
/* ============ GOALS ============ */
.goals {
padding: 4px 16px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.goal__top {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 6px;
}
.goal__top b {
font-variant-numeric: tabular-nums;
}
.goal__top span:last-child {
color: var(--muted);
}
.bar-bg {
height: 8px;
border-radius: 999px;
background: var(--bg);
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--accent));
transition: width 0.6s ease;
}
/* ============ FEED ============ */
.feed {
padding: 4px 16px 16px;
display: flex;
flex-direction: column;
gap: 0;
}
.feed li {
display: flex;
gap: 12px;
padding: 12px 0;
animation: fade 0.3s ease;
}
.feed li + li {
border-top: 1px solid var(--line);
}
.feed__dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-top: 4px;
flex: none;
background: var(--brand);
}
.feed__dot.is-ok {
background: var(--ok);
}
.feed__dot.is-warn {
background: var(--warn);
}
.feed__txt {
font-size: 13px;
}
.feed__txt b {
font-weight: 700;
}
.feed__time {
font-size: 11.5px;
color: var(--muted);
margin-top: 2px;
}
.livebadge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--ok);
}
.livebadge i {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
animation: pulse 1.4s ease infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(47, 158, 111, 0.4);
}
50% {
opacity: 0.6;
box-shadow: 0 0 0 6px rgba(47, 158, 111, 0);
}
}
/* ============ PROFILE ============ */
.profilecard {
text-align: center;
padding: 20px 16px;
}
.profstats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 16px;
}
.profstats div {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 6px;
background: var(--bg);
border-radius: var(--r-md);
}
.profstats b {
font-size: 20px;
font-weight: 800;
}
.profstats span {
font-size: 11.5px;
color: var(--muted);
}
.settings li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
font-size: 14px;
font-weight: 500;
min-height: 44px;
}
.settings li + li {
border-top: 1px solid var(--line);
}
.toggle {
width: 46px;
height: 28px;
border-radius: 999px;
border: none;
background: var(--line-2);
position: relative;
transition: background 0.2s;
flex: none;
}
.toggle i {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--white);
box-shadow: var(--sh-1);
transition: transform 0.2s;
}
.toggle.is-on {
background: var(--brand);
}
.toggle.is-on i {
transform: translateX(18px);
}
/* ============ TAB BAR ============ */
.tabbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 6;
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
background: color-mix(in srgb, var(--white) 90%, transparent);
backdrop-filter: saturate(160%) blur(12px);
border-top: 1px solid var(--line);
}
.tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-height: 52px;
padding: 6px 0;
border: none;
background: transparent;
color: var(--muted);
font-size: 11px;
font-weight: 600;
border-radius: 12px;
transition: color 0.15s, background 0.15s;
}
.tab svg {
transition: transform 0.15s;
}
.tab:active svg {
transform: scale(0.88);
}
.tab.is-active {
color: var(--brand-d);
background: var(--brand-50);
}
/* ============ TOAST ============ */
.toast {
position: fixed;
left: 50%;
bottom: 28px;
transform: translate(-50%, 24px);
background: var(--ink);
color: var(--white);
font-size: 13px;
font-weight: 600;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--sh-3);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 50;
max-width: 90vw;
}
.toast.is-on {
opacity: 1;
transform: translate(-50%, 0);
}
/* ============ RESPONSIVE ============ */
@media (max-width: 720px) {
.stage {
padding: 0;
place-items: stretch;
}
.phone {
max-width: none;
width: 100%;
height: 100dvh;
border-radius: 0;
border: none;
box-shadow: none;
}
}
@media (max-width: 360px) {
.hero__value {
font-size: 32px;
}
.kpi {
width: 132px;
}
.topbar__name {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}(function () {
"use strict";
/* ---------- helpers ---------- */
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var rnd = function (min, max) { return Math.random() * (max - min) + min; };
var fmt = function (n) { return Math.round(n).toLocaleString("en-US"); };
var toastEl = $("#toast");
var toastT;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-on");
clearTimeout(toastT);
toastT = setTimeout(function () { toastEl.classList.remove("is-on"); }, 2200);
}
/* ---------- SVG path builders ---------- */
// Build a smooth-ish polyline path scaled into a viewBox of w x h.
function pathFor(values, w, h, pad) {
pad = pad || 2;
var max = Math.max.apply(null, values);
var min = Math.min.apply(null, values);
var span = max - min || 1;
var step = w / (values.length - 1);
var pts = values.map(function (v, i) {
var x = i * step;
var y = pad + (h - pad * 2) * (1 - (v - min) / span);
return [x, y];
});
var line = pts.map(function (p, i) {
return (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1);
}).join(" ");
var area = line + " L" + w + " " + h + " L0 " + h + " Z";
return { line: line, area: area, pts: pts };
}
/* ---------- DATA MODEL ---------- */
function rollData() {
var spend = [];
for (var i = 0; i < 7; i++) spend.push(rnd(180, 460));
var balance = [];
var b = rnd(110000, 124000);
for (var j = 0; j < 12; j++) { b += rnd(-2200, 4200); balance.push(b); }
return {
balance: Math.round(balance[balance.length - 1]),
balanceDelta: +rnd(-3.5, 7.2).toFixed(1),
balanceSeries: balance,
spend: spend,
spendTotal: Math.round(spend.reduce(function (a, c) { return a + c; }, 0)),
spendDelta: +rnd(-6, 4).toFixed(1),
kpis: [
{ label: "Income", value: rnd(6200, 8400), prefix: "$", delta: rnd(1, 9) },
{ label: "Savings", value: rnd(18, 34), suffix: "%", delta: rnd(-2, 6) },
{ label: "Investments", value: rnd(41000, 52000), prefix: "$", delta: rnd(-4, 8) },
{ label: "Credit score", value: rnd(710, 805), delta: rnd(-1.5, 2.5) }
],
budget: [
{ name: "Housing", color: "var(--brand)", pct: Math.round(rnd(34, 44)) },
{ name: "Food & drink", color: "var(--accent)", pct: Math.round(rnd(16, 26)) },
{ name: "Lifestyle", color: "var(--warn)", pct: Math.round(rnd(10, 20)) }
],
cashflow: [
[6.4, 4.1], [7.0, 5.2], [6.8, 4.6], [7.4, 5.9], [6.9, 4.2], [7.7, 5.0]
].map(function (m) { return [m[0] + rnd(-0.6, 0.6), m[1] + rnd(-0.5, 0.5)]; })
};
}
var TX = [
{ ico: "☕", bg: "#fdeede", name: "Blue Bottle Coffee", cat: "Food & drink", amt: -6.5 },
{ ico: "💼", bg: "#e7fbf5", name: "Salary · Orbital Labs", cat: "Income", amt: 4280 },
{ ico: "🛒", bg: "#eef0ff", name: "Greenfield Market", cat: "Groceries", amt: -84.3 },
{ ico: "🎬", bg: "#fde9e6", name: "Nimbus Streaming", cat: "Entertainment", amt: -14.99 }
];
var FEED_TEMPLATES = [
{ t: "Card payment to <b>Greenfield Market</b>", d: "ok" },
{ t: "Transfer received from <b>Orbital Labs</b>", d: "ok" },
{ t: "Budget alert: <b>Lifestyle</b> 80% used", d: "warn" },
{ t: "New device signed in · <b>iPhone 16</b>", d: "" },
{ t: "Savings goal <b>Vacation</b> reached 60%", d: "ok" },
{ t: "Subscription renewed · <b>Nimbus</b>", d: "" }
];
var GOALS = [
{ name: "Emergency fund", have: 9200, target: 12000 },
{ name: "Vacation · Lisbon", have: 1850, target: 3000 },
{ name: "New laptop", have: 740, target: 2200 }
];
var DONUT_C = 2 * Math.PI * 48; // circumference
/* ---------- RENDERERS ---------- */
function renderHero(d, animate) {
var deltaEl = $("#heroDelta");
var up = d.balanceDelta >= 0;
deltaEl.className = "chip " + (up ? "chip--up" : "chip--down");
deltaEl.innerHTML =
'<svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true"><path d="' +
(up ? "M6 2.5 10 8H2z" : "M6 9.5 2 4h8z") + '" fill="currentColor"/></svg> ' +
Math.abs(d.balanceDelta) + "%";
countUp($("#heroValue"), d.balance, animate);
var diff = Math.round(d.balance * d.balanceDelta / 100);
$("#heroSub").textContent =
(up ? "Up " : "Down ") + "$" + fmt(Math.abs(diff)) + " vs last month across 4 accounts";
var p = pathFor(d.balanceSeries, 320, 56, 6);
$("#heroLine").setAttribute("d", p.line);
$("#heroArea").setAttribute("d", p.area);
}
function renderSpend(d) {
$("#spendVal").textContent = "$" + fmt(d.spendTotal);
var de = $("#spendDelta");
var down = d.spendDelta <= 0;
de.className = "chip chip--sm " + (down ? "chip--up" : "chip--down");
de.innerHTML =
'<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true"><path d="' +
(down ? "M6 9.5 2 4h8z" : "M6 2.5 10 8H2z") + '" fill="currentColor"/></svg> ' +
Math.abs(d.spendDelta) + "%";
var p = pathFor(d.spend, 320, 120, 8);
$("#spendLine").setAttribute("d", p.line);
$("#spendArea").setAttribute("d", p.area);
var last = p.pts[p.pts.length - 1];
var dot = $("#spendDot");
dot.setAttribute("cx", last[0]);
dot.setAttribute("cy", last[1]);
}
function renderKpis(d) {
var rail = $("#chiprail");
rail.innerHTML = "";
d.kpis.forEach(function (k) {
var spark = [];
for (var i = 0; i < 12; i++) spark.push(rnd(0, 100));
var p = pathFor(spark, 118, 26, 2);
var up = k.delta >= 0;
var val = (k.prefix || "") + fmt(k.value) + (k.suffix || "");
var li = document.createElement("div");
li.className = "kpi";
li.setAttribute("role", "listitem");
li.tabIndex = 0;
li.innerHTML =
'<div class="kpi__top"><span class="kpi__label">' + k.label + "</span>" +
'<span class="chip chip--sm ' + (up ? "chip--up" : "chip--down") + '">' +
'<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true"><path d="' +
(up ? "M6 2.5 10 8H2z" : "M6 9.5 2 4h8z") + '" fill="currentColor"/></svg> ' +
Math.abs(k.delta).toFixed(1) + "%</span></div>" +
'<div class="kpi__val">' + val + "</div>" +
'<svg class="kpi__spark" viewBox="0 0 118 26" preserveAspectRatio="none" aria-hidden="true">' +
'<path d="' + p.line + '" fill="none" stroke="' + (up ? "var(--ok)" : "var(--danger)") +
'" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
li.addEventListener("click", function () { toast(k.label + ": " + val); });
li.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toast(k.label + ": " + val); }
});
rail.appendChild(li);
});
}
function renderTx() {
var ul = $("#txlist");
ul.innerHTML = "";
TX.forEach(function (t) {
var inc = t.amt > 0;
var li = document.createElement("li");
li.innerHTML =
'<span class="txico" style="background:' + t.bg + '">' + t.ico + "</span>" +
'<span class="txmain"><span class="txname">' + t.name + "</span>" +
'<span class="txcat">' + t.cat + "</span></span>" +
'<span class="txamt' + (inc ? " is-in" : "") + '">' +
(inc ? "+" : "−") + "$" + Math.abs(t.amt).toFixed(2) + "</span>";
ul.appendChild(li);
});
}
function renderDonut(d) {
var used = d.budget.reduce(function (a, c) { return a + c.pct; }, 0);
$("#donutBig").textContent = used + "%";
var segs = ["#seg1", "#seg2", "#seg3"];
var offset = 0;
d.budget.forEach(function (b, i) {
var len = DONUT_C * (b.pct / 100);
var seg = $(segs[i]);
seg.setAttribute("stroke-dasharray", len.toFixed(2) + " " + (DONUT_C - len).toFixed(2));
seg.setAttribute("stroke-dashoffset", (-offset).toFixed(2));
offset += len;
});
var leg = $("#legend");
leg.innerHTML = "";
d.budget.forEach(function (b) {
var li = document.createElement("li");
li.innerHTML = '<i style="background:' + b.color + '"></i>' +
'<span class="lname">' + b.name + "</span><b>" + b.pct + "%</b>";
leg.appendChild(li);
});
}
function renderBars(d) {
var g = $("#bars");
g.innerHTML = "";
var max = 0;
d.cashflow.forEach(function (m) { max = Math.max(max, m[0], m[1]); });
var groupW = 320 / d.cashflow.length;
var bw = 11;
d.cashflow.forEach(function (m, i) {
var cx = i * groupW + groupW / 2;
[["in", m[0], cx - bw - 2], ["out", m[1], cx + 2]].forEach(function (pair) {
var h = (pair[1] / max) * 130;
var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("class", "bar bar--" + pair[0]);
rect.setAttribute("x", pair[2].toFixed(1));
rect.setAttribute("y", (140 - h).toFixed(1));
rect.setAttribute("width", bw);
rect.setAttribute("height", h.toFixed(1));
rect.setAttribute("rx", 4);
g.appendChild(rect);
});
});
}
function renderGoals() {
var ul = $("#goals");
ul.innerHTML = "";
GOALS.forEach(function (gItem) {
var pct = Math.min(100, Math.round((gItem.have / gItem.target) * 100));
var li = document.createElement("li");
li.innerHTML =
'<div class="goal__top"><b>' + gItem.name + "</b>" +
"<span>$" + fmt(gItem.have) + " / $" + fmt(gItem.target) + "</span></div>" +
'<div class="bar-bg"><div class="bar-fill" style="width:0%"></div></div>';
ul.appendChild(li);
requestAnimationFrame(function () {
$(".bar-fill", li).style.width = pct + "%";
});
});
}
function renderFeed() {
var ul = $("#feed");
ul.innerHTML = "";
var times = ["just now", "2m ago", "11m ago", "38m ago", "1h ago", "2h ago"];
FEED_TEMPLATES.forEach(function (f, i) {
addFeedItem(f, times[i] || "earlier", false);
});
}
function addFeedItem(f, time, prepend) {
var ul = $("#feed");
var li = document.createElement("li");
li.innerHTML =
'<span class="feed__dot ' + (f.d ? "is-" + f.d : "") + '"></span>' +
'<span><span class="feed__txt">' + f.t + "</span>" +
'<div class="feed__time">' + time + "</div></span>";
if (prepend && ul.firstChild) ul.insertBefore(li, ul.firstChild);
else ul.appendChild(li);
while (ul.children.length > 8) ul.removeChild(ul.lastChild);
}
/* ---------- count-up animation ---------- */
function countUp(el, to, animate) {
var from = animate ? (parseInt((el.textContent || "0").replace(/[^\d-]/g, ""), 10) || 0) : to;
if (!animate) { el.textContent = fmt(to); return; }
var start = performance.now();
var dur = 600;
function frame(now) {
var t = Math.min(1, (now - start) / dur);
var eased = 1 - Math.pow(1 - t, 3);
el.textContent = fmt(from + (to - from) * eased);
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
/* ---------- render everything ---------- */
var data;
function renderAll(animate) {
renderHero(data, animate);
renderSpend(data);
renderKpis(data);
renderDonut(data);
renderBars(data);
}
data = rollData();
renderAll(false);
renderTx();
renderGoals();
renderFeed();
/* ---------- TAB SWITCHING ---------- */
var scroller = $("#scroller");
var titles = { home: "Home", stats: "Stats", activity: "Activity", profile: "Profile" };
$$(".tab").forEach(function (tab) {
tab.addEventListener("click", function () {
var view = tab.dataset.view;
$$(".tab").forEach(function (t) {
var on = t === tab;
t.classList.toggle("is-active", on);
if (on) t.setAttribute("aria-current", "page");
else t.removeAttribute("aria-current");
});
$$(".view").forEach(function (v) {
var on = v.id === "view-" + view;
v.hidden = !on;
v.classList.toggle("is-active", on);
});
scroller.scrollTop = 0;
});
});
/* ---------- REFRESH / PULL-TO-REFRESH ---------- */
var refreshBtn = $("#refreshBtn");
var ptr = $("#ptr");
var busy = false;
function doRefresh() {
if (busy) return;
busy = true;
refreshBtn.classList.add("is-spinning");
ptr.classList.add("is-on");
setTimeout(function () {
data = rollData();
renderAll(true);
renderGoals();
ptr.classList.remove("is-on");
refreshBtn.classList.remove("is-spinning");
busy = false;
toast("Updated · " + new Date().toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }));
}, 850);
}
refreshBtn.addEventListener("click", doRefresh);
// touch pull-to-refresh on the scroller (when at top, drag down)
var startY = 0, pulling = false;
scroller.addEventListener("touchstart", function (e) {
if (scroller.scrollTop <= 0) { startY = e.touches[0].clientY; pulling = true; }
}, { passive: true });
scroller.addEventListener("touchmove", function (e) {
if (!pulling) return;
var dy = e.touches[0].clientY - startY;
if (dy > 70 && scroller.scrollTop <= 0) { pulling = false; doRefresh(); }
}, { passive: true });
scroller.addEventListener("touchend", function () { pulling = false; });
/* ---------- TOGGLES ---------- */
$$(".toggle").forEach(function (tg) {
tg.addEventListener("click", function () {
var on = !tg.classList.contains("is-on");
tg.classList.toggle("is-on", on);
tg.setAttribute("aria-checked", String(on));
toast(tg.getAttribute("aria-label") + (on ? " on" : " off"));
});
});
/* ---------- WIDGET MENUS ---------- */
$$(".menu").forEach(function (m) {
m.addEventListener("click", function () { toast("Widget options coming soon"); });
});
/* ---------- LIVE TICK ---------- */
setInterval(function () {
if (busy) return;
// nudge the hero balance a touch so the dashboard feels alive
data.balance += Math.round(rnd(-180, 240));
countUp($("#heroValue"), data.balance, true);
}, 4200);
// live activity feed when on the Activity view
setInterval(function () {
if ($("#view-activity").hidden) return;
var f = FEED_TEMPLATES[Math.floor(rnd(0, FEED_TEMPLATES.length))];
addFeedItem(f, "just now", true);
}, 6000);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Dashboard — Mobile-first layout</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="stage">
<!-- Phone frame -->
<div class="phone" role="application" aria-label="Lumen Finance mobile dashboard">
<!-- Sticky top bar -->
<header class="topbar" role="banner">
<div class="topbar__lead">
<span class="avatar" aria-hidden="true">AR</span>
<div class="topbar__hello">
<span class="topbar__greet">Good morning</span>
<span class="topbar__name">Ana Reyes</span>
</div>
</div>
<div class="topbar__actions">
<button class="iconbtn" id="refreshBtn" type="button" aria-label="Refresh data">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
d="M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button class="iconbtn iconbtn--badge" type="button" aria-label="Notifications, 3 unread">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="dot" aria-hidden="true">3</span>
</button>
</div>
</header>
<!-- Pull-to-refresh hint -->
<div class="ptr" id="ptr" aria-hidden="true">
<span class="ptr__spin"></span>
<span class="ptr__txt">Refreshing…</span>
</div>
<main class="scroller" role="main" id="scroller">
<!-- ============ HOME VIEW ============ -->
<section class="view is-active" id="view-home" role="region" aria-label="Home overview">
<!-- Hero KPI -->
<article class="hero">
<div class="hero__top">
<span class="hero__label">Total balance</span>
<span class="chip chip--up" id="heroDelta">
<svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true">
<path d="M6 2.5 10 8H2z" fill="currentColor" />
</svg>
4.8%
</span>
</div>
<div class="hero__value">
<span class="hero__cur">$</span><span id="heroValue">128,940</span>
</div>
<p class="hero__sub" id="heroSub">Up $5,940 vs last month across 4 accounts</p>
<svg class="hero__spark" viewBox="0 0 320 64" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="heroFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="rgba(255,255,255,0.32)" />
<stop offset="1" stop-color="rgba(255,255,255,0)" />
</linearGradient>
</defs>
<path id="heroArea" fill="url(#heroFill)" d="" />
<path id="heroLine" fill="none" stroke="rgba(255,255,255,0.92)" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round" d="" />
</svg>
</article>
<!-- Swipeable KPI chips -->
<div class="chiprail" role="list" aria-label="Quick metrics, scroll horizontally" id="chiprail">
<!-- chips injected -->
</div>
<!-- Mini line chart widget -->
<article class="card">
<header class="card__head">
<div>
<h2 class="card__title">Spending</h2>
<span class="card__meta">Last 7 days</span>
</div>
<button class="menu" type="button" aria-label="Spending widget options">⋯</button>
</header>
<div class="card__body">
<div class="trend">
<span class="trend__val" id="spendVal">$2,410</span>
<span class="chip chip--down chip--sm" id="spendDelta">
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M6 9.5 2 4h8z" fill="currentColor" />
</svg>
2.1%
</span>
</div>
<svg class="linechart" viewBox="0 0 320 120" preserveAspectRatio="none" role="img"
aria-label="7-day spending trend line chart">
<defs>
<linearGradient id="spendFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="rgba(91,91,240,0.22)" />
<stop offset="1" stop-color="rgba(91,91,240,0)" />
</linearGradient>
</defs>
<line class="gl" x1="0" y1="30" x2="320" y2="30" />
<line class="gl" x1="0" y1="60" x2="320" y2="60" />
<line class="gl" x1="0" y1="90" x2="320" y2="90" />
<path id="spendArea" fill="url(#spendFill)" d="" />
<path id="spendLine" fill="none" stroke="var(--brand)" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round" d="" />
<circle id="spendDot" r="4" fill="var(--white)" stroke="var(--brand)" stroke-width="2.5" />
</svg>
<div class="dows" aria-hidden="true">
<span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span>
</div>
</div>
</article>
<!-- Transactions list widget -->
<article class="card">
<header class="card__head">
<div>
<h2 class="card__title">Recent activity</h2>
<span class="card__meta">4 transactions</span>
</div>
<button class="menu" type="button" aria-label="Activity widget options">⋯</button>
</header>
<ul class="txlist" id="txlist" aria-label="Recent transactions">
<!-- list injected -->
</ul>
</article>
<!-- Donut budget widget -->
<article class="card">
<header class="card__head">
<div>
<h2 class="card__title">Budget split</h2>
<span class="card__meta">This month</span>
</div>
<button class="menu" type="button" aria-label="Budget widget options">⋯</button>
</header>
<div class="card__body donutrow">
<svg class="donut" viewBox="0 0 120 120" role="img" aria-label="Budget split donut chart">
<circle class="donut__track" cx="60" cy="60" r="48" />
<circle class="donut__seg" id="seg1" cx="60" cy="60" r="48" stroke="var(--brand)" />
<circle class="donut__seg" id="seg2" cx="60" cy="60" r="48" stroke="var(--accent)" />
<circle class="donut__seg" id="seg3" cx="60" cy="60" r="48" stroke="var(--warn)" />
<text x="60" y="56" class="donut__big" id="donutBig">68%</text>
<text x="60" y="74" class="donut__cap">used</text>
</svg>
<ul class="legend" id="legend"><!-- legend injected --></ul>
</div>
</article>
</section>
<!-- ============ STATS VIEW ============ -->
<section class="view" id="view-stats" role="region" aria-label="Statistics" hidden>
<article class="card">
<header class="card__head">
<div>
<h2 class="card__title">Monthly cashflow</h2>
<span class="card__meta">In vs out · last 6 months</span>
</div>
<button class="menu" type="button" aria-label="Cashflow options">⋯</button>
</header>
<div class="card__body">
<svg class="barchart" viewBox="0 0 320 150" role="img" aria-label="Cashflow bar chart">
<g id="bars"></g>
</svg>
<div class="dows dows--mo" aria-hidden="true">
<span>Jan</span><span>Feb</span><span>Mar</span><span>Apr</span><span>May</span><span>Jun</span>
</div>
<div class="barlegend">
<span><i class="sw sw--in"></i>Income</span>
<span><i class="sw sw--out"></i>Spend</span>
</div>
</div>
</article>
<article class="card">
<header class="card__head">
<div><h2 class="card__title">Savings goals</h2></div>
<button class="menu" type="button" aria-label="Goals options">⋯</button>
</header>
<ul class="goals" id="goals"><!-- goals injected --></ul>
</article>
</section>
<!-- ============ ACTIVITY VIEW ============ -->
<section class="view" id="view-activity" role="region" aria-label="Activity feed" hidden>
<article class="card">
<header class="card__head">
<div><h2 class="card__title">Activity feed</h2><span class="card__meta">Live</span></div>
<span class="livebadge"><i></i>Live</span>
</header>
<ul class="feed" id="feed"><!-- feed injected --></ul>
</article>
</section>
<!-- ============ PROFILE VIEW ============ -->
<section class="view" id="view-profile" role="region" aria-label="Profile" hidden>
<article class="card profilecard">
<span class="avatar avatar--lg" aria-hidden="true">AR</span>
<h2 class="card__title">Ana Reyes</h2>
<p class="card__meta">Premium plan · member since 2021</p>
<div class="profstats">
<div><b id="pAccounts">4</b><span>Accounts</span></div>
<div><b id="pCards">2</b><span>Cards</span></div>
<div><b id="pGoals">3</b><span>Goals</span></div>
</div>
</article>
<article class="card">
<ul class="settings">
<li><span>Notifications</span><button class="toggle is-on" type="button" role="switch" aria-checked="true" aria-label="Notifications"><i></i></button></li>
<li><span>Face ID login</span><button class="toggle is-on" type="button" role="switch" aria-checked="true" aria-label="Face ID login"><i></i></button></li>
<li><span>Weekly report</span><button class="toggle" type="button" role="switch" aria-checked="false" aria-label="Weekly report"><i></i></button></li>
</ul>
</article>
</section>
</main>
<!-- Fixed bottom tab bar -->
<nav class="tabbar" role="navigation" aria-label="Primary">
<button class="tab is-active" type="button" data-view="home" aria-current="page">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M3 11 12 3l9 8M5 9.5V21h14V9.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>Home</span>
</button>
<button class="tab" type="button" data-view="stats">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M5 19V10M12 19V5m7 14v-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span>Stats</span>
</button>
<button class="tab" type="button" data-view="activity">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M3 12h4l2 6 4-14 2 8h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>Activity</span>
</button>
<button class="tab" type="button" data-view="profile">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 20c1.5-4 5-5 8-5s6.5 1 8 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span>Profile</span>
</button>
</nav>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Mobile-first layout
A phone-shaped dashboard for the fictional Lumen Finance app, designed thumb-first and then scaled up to a centered device frame on desktop. A sticky top bar carries the greeting, avatar, and a notification bell with an unread badge, while a refresh icon kicks off a spinner. Below it the hero card shows the total balance as a huge tabular figure with a green or red delta chip and a plain-language summary, backed by a translucent inline-SVG sparkline that bleeds to the card edges.
Under the hero, a horizontally scrolling rail of KPI chips snaps into place — income, savings, investments, credit score — each with its own colored mini sparkline and up/down delta. The stacked widget cards beneath cover the core chart types: a 7-day spending line chart with gridlines and an end-point dot, a recent-activity transaction list with category icons, and a budget-split donut drawn from stroke-dash math with a matching legend. The Stats and Activity tabs add a six-month cashflow bar chart, animated savings-goal bars, and a live-updating feed.
The fixed bottom tab bar swaps four full views (Home, Stats, Activity, Profile) and resets scroll on switch. Tapping the refresh button — or pulling the scroll view down past its top on touch — re-rolls every dataset and counts the numbers up to their new values, and a slow live tick nudges the balance so the screen feels alive. All targets meet the 44px touch minimum, controls are keyboard-operable with visible focus rings, landmark roles and aria labels are in place, and below 720px the phone frame expands to fill the viewport down to about 360px.