CSS
Module 11: Projects
Project 2: A Responsive Analytics Dashboard
Build an admin dashboard with a grid-area shell, a collapsible sidebar, container-query stat cards, a CSS-only bar chart, a scrolling data table and a persistent theme toggle.
8 minute lessonBeginner to Intermediate
The first project was a page. This one is an application shell, which has different problems: a layout with fixed regions, content that has to scroll in the right place, components that live in unpredictable container sizes, and data tables that are wider than a phone.
You are building "Orbit", a made-up analytics dashboard. As before, attempt each step yourself, then compare with the reference solution.
Requirements#
Layout
- A page shell using CSS Grid with named areas: a sidebar, a top bar and a main content area.
- On screens below 960px, the sidebar becomes an off-canvas drawer opened from a button in the top bar. Above that width, it is a permanent column.
- The main content area must never cause the page itself to scroll horizontally. It uses
minmax(0, 1fr)where needed.
Content
- Four stat cards that fill the width using
auto-fit. Each card uses a container query to put its number and trend side by side when it has the room. - A weekly bar chart drawn with CSS only. Each bar's height comes from a custom property in the HTML, and the chart has an accessible text description.
- A data table with at least five columns that scrolls horizontally inside its own panel on small screens.
- Status badges that communicate status with text and an icon shape, not only by color.
Theming and quality
- A light and dark theme using tokens, with a toggle button that saves the choice and avoids a flash on reload.
- Respect
prefers-reduced-motion. - Visible focus on every interactive element, and
aria-expandedandaria-pressedon the toggle buttons. - All sizes use
remor fluid units. No fixed heights on text containers.
File structure#
orbit/
├── index.html
├── styles.css
└── app.js
Steps#
- Sketch the regions on paper: sidebar, topbar, main. Decide what scrolls. The sidebar should stay in view, and the main area should scroll with the page.
- Write the HTML shell with semantic landmarks:
header,nav,main. Put placeholder content inside. - Build the mobile layout first. One column. The sidebar is
position: fixedand translated off-screen. - Add the desktop grid at 60rem. Switch to
grid-template-areaswith a sidebar column and aminmax(0, 1fr)content column. Remove the transform from the sidebar and make it sticky. - Add the drawer behavior with a small script that toggles a class and
aria-expanded. Close it on Escape and when a link is clicked. - Build the stats. Wrap each card in a container element and use
@containerinside. - Build the chart with grid and custom properties. Add a text alternative.
- Build the table panel. Give the scroll container
overflow-x: autoand the table amin-width. - Add tokens, the dark theme and the toggle.
- Test at 320px, 600px, 959px, 960px and 1440px. Test with the keyboard and in both themes.
Reference solution#
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Orbit: Overview</title>
<script>
try {
const t = localStorage.getItem("orbit-theme");
if (t) document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
<link rel="stylesheet" href="styles.css">
</head>
<body class="app">
<header class="topbar">
<button class="icon-btn" id="menu-btn" aria-expanded="false" aria-controls="sidebar">
<span aria-hidden="true">☰</span><span class="visually-hidden">Toggle navigation</span>
</button>
<h1 class="topbar__title">Overview</h1>
<button class="icon-btn" id="theme-btn" aria-pressed="false">
<span aria-hidden="true">◐</span><span class="visually-hidden">Dark mode</span>
</button>
</header>
<nav class="sidebar" id="sidebar" aria-label="Main">
<p class="sidebar__brand">Orbit</p>
<ul class="sidebar__list">
<li><a href="#" aria-current="page">Overview</a></li>
<li><a href="#">Reports</a></li>
<li><a href="#">Customers</a></li>
<li><a href="#">Settings</a></li>
</ul>
</nav>
<main class="content">
<section class="stats" aria-label="Key numbers">
<div class="stat-wrap"><article class="stat"><h2 class="stat__label">Revenue</h2><p class="stat__value">$48.2k</p><p class="stat__trend stat__trend--up">▲ 12%</p></article></div>
<div class="stat-wrap"><article class="stat"><h2 class="stat__label">Orders</h2><p class="stat__value">1,284</p><p class="stat__trend stat__trend--up">▲ 4%</p></article></div>
<div class="stat-wrap"><article class="stat"><h2 class="stat__label">Refunds</h2><p class="stat__value">37</p><p class="stat__trend stat__trend--down">▼ 9%</p></article></div>
<div class="stat-wrap"><article class="stat"><h2 class="stat__label">New users</h2><p class="stat__value">312</p><p class="stat__trend stat__trend--up">▲ 18%</p></article></div>
</section>
<section class="panel" aria-labelledby="chart-title">
<h2 class="panel__title" id="chart-title">Revenue this week</h2>
<div class="bars" role="img"
aria-label="Revenue by day: Mon 40, Tue 62, Wed 55, Thu 78, Fri 90, Sat 48, Sun 35 thousand dollars">
<div class="bar" style="--v: 40"><span>Mon</span></div>
<div class="bar" style="--v: 62"><span>Tue</span></div>
<div class="bar" style="--v: 55"><span>Wed</span></div>
<div class="bar" style="--v: 78"><span>Thu</span></div>
<div class="bar" style="--v: 90"><span>Fri</span></div>
<div class="bar" style="--v: 48"><span>Sat</span></div>
<div class="bar" style="--v: 35"><span>Sun</span></div>
</div>
</section>
<section class="panel" aria-labelledby="orders-title">
<h2 class="panel__title" id="orders-title">Recent orders</h2>
<div class="table-scroll" tabindex="0" role="region" aria-labelledby="orders-title">
<table>
<thead>
<tr><th scope="col">Order</th><th scope="col">Customer</th><th scope="col">Date</th><th scope="col">Total</th><th scope="col">Status</th></tr>
</thead>
<tbody>
<tr><td>#1042</td><td>Amara Okafor</td><td>Nov 5</td><td>$128.00</td><td><span class="badge badge--ok">Paid</span></td></tr>
<tr><td>#1041</td><td>Liam Chen</td><td>Nov 5</td><td>$54.50</td><td><span class="badge badge--wait">Pending</span></td></tr>
<tr><td>#1040</td><td>Sofia Ramirez</td><td>Nov 4</td><td>$310.00</td><td><span class="badge badge--ok">Paid</span></td></tr>
<tr><td>#1039</td><td>Noah Patel</td><td>Nov 4</td><td>$19.99</td><td><span class="badge badge--bad">Refunded</span></td></tr>
<tr><td>#1038</td><td>Elena Petrova</td><td>Nov 3</td><td>$87.25</td><td><span class="badge badge--ok">Paid</span></td></tr>
</tbody>
</table>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
styles.css:
:root {
color-scheme: light dark;
--bg: #f5f6fa;
--panel: #ffffff;
--text: #141a2a;
--muted: #566074;
--border: #e1e5ee;
--brand: #2f5bea;
--ok: #067647;
--wait: #93370d;
--bad: #b42318;
--radius: 14px;
--gap: 1rem;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0b0f1a; --panel: #151b2c; --text: #eef1f8; --muted: #9aa5bd; --border: #27304a;
--brand: #7b9bff; --ok: #5fd59b; --wait: #ffb870; --bad: #ff8e85;
}
}
:root[data-theme="dark"] {
--bg: #0b0f1a; --panel: #151b2c; --text: #eef1f8; --muted: #9aa5bd; --border: #27304a;
--brand: #7b9bff; --ok: #5fd59b; --wait: #ffb870; --bad: #ff8e85;
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font: 1rem/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); }
button { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
.visually-hidden {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* Shell: mobile first */
.app {
display: grid;
min-height: 100dvh;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr;
grid-template-areas: "top" "main";
}
.topbar {
grid-area: top;
position: sticky; top: 0; z-index: 50;
display: flex; align-items: center; gap: var(--gap);
padding: 0.75rem var(--gap);
background: var(--panel); border-bottom: 1px solid var(--border);
}
.topbar__title { margin: 0 auto 0 0; font-size: 1.25rem; }
.content { grid-area: main; display: grid; gap: var(--gap); align-content: start; padding: var(--gap); min-width: 0; }
.icon-btn {
display: inline-grid; place-items: center;
width: 2.75rem; height: 2.75rem;
border: 1px solid var(--border); border-radius: 10px;
background: transparent; cursor: pointer;
}
.icon-btn:hover { background: color-mix(in srgb, var(--brand) 12%, transparent); }
/* Sidebar: drawer on small screens */
.sidebar {
position: fixed; inset: 0 auto 0 0; z-index: 60;
width: min(16rem, 85vw);
padding: var(--gap);
background: var(--panel); border-right: 1px solid var(--border);
transform: translateX(-100%);
transition: transform 200ms ease;
visibility: hidden;
}
.sidebar.is-open { transform: none; visibility: visible; }
.sidebar__brand { margin: 0 0 1.5rem; font-size: 1.4rem; font-weight: 800; }
.sidebar__list { display: grid; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
.sidebar__list a {
display: block; padding: 0.65rem 0.8rem; border-radius: 8px;
color: var(--text); text-decoration: none;
}
.sidebar__list a:hover { background: color-mix(in srgb, var(--brand) 12%, transparent); }
.sidebar__list a[aria-current="page"] { background: var(--brand); color: var(--bg); font-weight: 600; }
/* Desktop shell */
@media (min-width: 60rem) {
.app {
grid-template-columns: 15rem minmax(0, 1fr);
grid-template-areas: "side top" "side main";
}
.sidebar {
grid-area: side;
position: sticky; top: 0; align-self: start;
width: auto; height: 100dvh;
transform: none; visibility: visible; transition: none;
}
#menu-btn { display: none; }
.content { padding: 1.5rem; gap: 1.5rem; }
}
/* Panels */
.panel { padding: var(--gap); background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); }
.panel__title { margin: 0 0 var(--gap); font-size: 1.05rem; }
/* Stats with container queries */
.stats { display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr)); }
.stat-wrap { container-type: inline-size; }
.stat {
display: grid; gap: 0.25rem;
padding: var(--gap);
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
}
.stat__label { margin: 0; font-size: 0.85rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
.stat__value { margin: 0; font-size: 1.9rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.stat__trend { margin: 0; font-size: 0.9rem; font-weight: 600; }
.stat__trend--up { color: var(--ok); }
.stat__trend--down { color: var(--bad); }
@container (min-width: 16rem) {
.stat { grid-template-columns: 1fr auto; align-items: end; }
.stat__label { grid-column: 1 / -1; }
}
/* CSS-only bar chart */
.bars {
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
align-items: end; gap: clamp(0.4rem, 2vw, 1rem);
height: 14rem; padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.bar {
position: relative;
height: calc(var(--v) * 1%);
min-height: 0.25rem;
background: var(--brand);
border-radius: 6px 6px 0 0;
transform-origin: bottom;
}
.bar span { position: absolute; top: 100%; left: 0; right: 0; padding-top: 0.35rem; font-size: 0.75rem; text-align: center; color: var(--muted); }
@media (prefers-reduced-motion: no-preference) {
@keyframes grow { from { transform: scaleY(0); } }
.bar { animation: grow 600ms cubic-bezier(0.2, 0.8, 0.2, 1) both; }
.bar:nth-child(2) { animation-delay: 60ms; } .bar:nth-child(3) { animation-delay: 120ms; }
.bar:nth-child(4) { animation-delay: 180ms; } .bar:nth-child(5) { animation-delay: 240ms; }
.bar:nth-child(6) { animation-delay: 300ms; } .bar:nth-child(7) { animation-delay: 360ms; }
}
/* Table */
.table-scroll { overflow-x: auto; border-radius: 8px; }
table { width: 100%; min-width: 34rem; border-collapse: collapse; }
th, td { padding: 0.75rem 1rem; text-align: start; border-bottom: 1px solid var(--border); white-space: nowrap; }
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
tbody tr:hover { background: color-mix(in srgb, var(--brand) 8%, transparent); }
.badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.15rem 0.65rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; border: 1px solid currentColor; }
.badge::before { content: ""; width: 0.5rem; height: 0.5rem; background: currentColor; }
.badge--ok { color: var(--ok); } .badge--ok::before { border-radius: 50%; }
.badge--wait { color: var(--wait); } .badge--wait::before { border-radius: 2px; }
.badge--bad { color: var(--bad); } .badge--bad::before { clip-path: polygon(50% 0, 100% 100%, 0 100%); }
app.js:
const root = document.documentElement;
const sidebar = document.querySelector("#sidebar");
const menuBtn = document.querySelector("#menu-btn");
const themeBtn = document.querySelector("#theme-btn");
function setMenu(open) {
sidebar.classList.toggle("is-open", open);
menuBtn.setAttribute("aria-expanded", String(open));
}
menuBtn.addEventListener("click", () => setMenu(!sidebar.classList.contains("is-open")));
sidebar.addEventListener("click", (e) => { if (e.target.closest("a")) setMenu(false); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") setMenu(false); });
function currentTheme() {
return root.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
}
function syncThemeButton() { themeBtn.setAttribute("aria-pressed", String(currentTheme() === "dark")); }
themeBtn.addEventListener("click", () => {
const next = currentTheme() === "dark" ? "light" : "dark";
root.dataset.theme = next;
try { localStorage.setItem("orbit-theme", next); } catch (e) {}
syncThemeButton();
});
syncThemeButton();
Things worth understanding in the solution#
- The sidebar uses
visibility: hiddenwhile closed, in addition to being off-screen. Without it, keyboard users could Tab into links that they cannot see. When the drawer opens,visibilityflips immediately. minmax(0, 1fr)on the content column is what stops a wide table from stretching the whole page. Remove it and watch the shell blow out..table-scrollhastabindex="0"so keyboard users can focus the region and scroll it with the arrow keys. It is labeled so screen readers announce what it is.- The chart is a visual and a description. The bars are decoration, and the
aria-labelcarries the data. For anything more complex, provide a real table alternative. - Badges use shape as well as color, which makes them readable without color vision.
- The dark tokens are repeated in the media query and in the
data-themerule, which is the cost of supporting both an OS default and a user override. - The inline script in the
headsets the saved theme before first paint, so there is no flash.
Acceptance checklist#
- There is no horizontal scrollbar on the page at any width from 320px to 1920px. The table scrolls inside its panel.
- At 959px the sidebar is a drawer. At 960px it is a fixed column, and the menu button disappears.
- You can open the drawer, move through its links with Tab, and close it with Escape. Focus never lands on a hidden link.
- Stat cards switch to a side-by-side arrangement based on their own width. Prove it by placing one inside a narrow container.
- The theme survives a reload with no visible flash.
- The bars grow in when the page loads, and do not animate if reduced motion is on.
- Every status can be understood in grayscale.
- Text meets contrast requirements in both themes.
Stretch goals#
- Add a second page that reuses the same shell, and mark the current page with
aria-current. - Make the table switch to a stacked "card" layout under 40rem using
display: blockon rows anddata-labelattributes with::before. - Add a collapsible sidebar on desktop that shrinks to icons, animated with
grid-template-columnsand a transition. - Replace the bar chart's hard-coded
--vwith values set from JavaScript, and add a tooltip that appears on hover and on keyboard focus. - Write a Playwright test that opens the drawer at 375px, presses Escape and asserts the drawer is closed and the menu button has focus. Fix the code if it does not.
Practice#
- Resize the window slowly from 1600px down to 320px while watching the Elements panel. List every width at which something changes, and decide whether each change is intentional. Remove or adjust any you cannot justify.
- Break the shell on purpose: change
minmax(0, 1fr)to1fr, then add a very long unbroken string to a table cell. Observe what happens, then restore the fix and explain the mechanism in two sentences. - Add a fifth stat card that spans two columns on wide screens using
grid-column: span 2, and verify its container query still behaves when the card is twice as wide.