Overview#
Dashboard is a complete admin interface for the fictional outdoor-gear shop behind Northstar Admin: KPI cards and charts, analytics, a full orders table, customers with a detail panel, a product catalogue with inventory, settings (profile, team, billing, notifications) and a sign-in screen, in light and dark themes. Every chart is SVG drawn by plain TypeScript; there are no UI frameworks or chart libraries. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.2. It builds to static HTML files and can be hosted anywhere. See it live at freeframe.dev/templates/dashboard.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/dashboard into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | What's on it |
|---|---|---|
/ |
src/pages/index.astro |
KPI cards with sparklines, revenue area chart (7 days / 30 days / 12 months), traffic-source donut, recent orders, activity feed |
/analytics |
src/pages/analytics.astro |
Visitors line chart, revenue bars, checkout funnel, order heatmap, top pages, countries |
/orders |
src/pages/orders.astro |
Searchable, filterable, sortable and paginated orders table with row selection and bulk actions |
/customers |
src/pages/customers.astro |
Customer list with segments and search, slide-over detail panel with a notes form |
/products |
src/pages/products.astro |
Product catalogue with category filter, grid/table view and inventory levels |
/settings |
src/pages/settings.astro |
Profile, team (invite modal), billing (plans, invoices), notification switches; /settings#billing opens a tab directly |
/login |
src/pages/login.astro |
Sign-in screen (demo: accepts any email and 8+ character password) |
| 404 | src/pages/404.astro |
Not-found page (most hosts show 404.html automatically) |
Project structure#
dashboard/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts, theme script), app shell, scripts
│ ├─ components/
│ │ ├─ Sidebar.astro workspace switcher, navigation, storage card, collapse button
│ │ ├─ Topbar.astro search, theme toggle, notifications and account menus
│ │ ├─ AppFooter.astro footer line under each page
│ │ └─ Toast.astro status message area used by toast()
│ ├─ data/
│ │ ├─ demo.ts seeded demo data: products, orders, customers
│ │ └─ nav.ts sidebar links and their icons
│ ├─ lib/
│ │ └─ site.ts site name, theme colours and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts charts, tables, menus, search, modals, forms
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (brand and chart tokens), component classes
├─ public/img/ favicon.svg, og-image.png (and its og-image.svg source)
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonsrc/scripts/main.ts is organised in numbered sections: 1 Helpers, 2 Demo data (imported from
src/data/demo.ts), 3 Theme, 4 Sidebar and drawer, 5 Dropdown menus and global search, 6 Toast, modals,
tabs and forms, 7 Charts, 8 Overview and analytics pages, 9 Orders table, 10 Customers, 11 Products,
12 Settings and sign-in, 13 Scroll reveal.
The layout wraps every page in the app shell: sidebar, a main column with the top bar, the page's
<main> and the footer, then any dialogs the page adds and the toast. /login and the 404 page pass
bare and provide their whole body themselves (with a bodyClass for their background).
Customising#
Brand and colours#
Tokens live at the top of src/styles/global.css. The brand-* scale (indigo by default) colours
buttons, links, active navigation and focus rings:
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
/* ...50 to 950 */
}Chart colours are separate CSS variables, defined once for light mode and again for dark mode just below
@theme: --chart-1 to --chart-5 (series colours), --chart-grid, --chart-text and
--chart-surface. Charts read them at paint time, so changing a variable recolours every chart and they
follow the theme without re-rendering. Tailwind rebuilds automatically in pnpm dev and pnpm build.
The browser theme colours (light and dark) are site.themeColor in src/lib/site.ts.
Typography#
Inter (interface) and JetBrains Mono (numbers and codes) load from Google Fonts by a <link> in
src/layouts/Layout.astro. Change that link and the --font-sans / --font-mono tokens. To avoid Google
Fonts, put the font files in public/fonts/, add @font-face rules to global.css and remove the
<link> and the two preconnect tags.
Content#
The demo data is generated in src/data/demo.ts from seeded random numbers, so it's the same on every
load:
| Name | What it is |
|---|---|
TODAY |
The fixed "current" date the demo is built around (new Date(2026, 9, 3), local time) |
NAMES |
Names used to generate customers and orders (with CITIES and CHANNELS in the same file) |
PRODUCTS |
Product catalogue: name, SKU, category, price, stock, capacity, units sold, colour and icon |
ORDERS |
48 generated orders (id, customer, date, status, badge class, channel, items, product, total) |
CUSTOMERS |
Customers derived from NAMES and their orders (email, city, joined, orders, spent, segment, recent orders) |
rng(), series() |
The seeded generators, also used for the chart series in main.ts |
Order statuses and their badge classes (Paid, Pending, Refunded, Failed) are STATUSES in the
same file. Chart series for the overview are REVENUE (section 8 of main.ts); the analytics charts are
built in the same section. Add a product by appending to PRODUCTS:
{ name: "Summit Trail Cap", sku: "AP-402", cat: "Apparel", price: 29, stock: 140, cap: 200, sold: 610, hue: "emerald", icon: "jacket" },hue must be a Hue and icon a ProductIcon (the type checker enforces both; their styles and SVGs
are HUES and ICONS in section 11). To use real data, replace these arrays with data from your API (for
example await fetch("/api/orders").then((r) => r.json())) and run the same render code once it arrives;
set TODAY to new Date(). Static text (page titles, labels, the activity feed and settings content) is in
the .astro pages.
Images and artwork#
There are no photos. Avatars are generated initials (avatar() in section 1), product images are SVG
icons on a coloured tile (thumb() in section 11), and charts are SVG. To show real product photos, put
them in public/img/ and change thumb() to return an <img src="${url("/img/...")}">. The favicon is
public/img/favicon.svg; the social image is public/img/og-image.png (1200×630, editable source
og-image.svg).
Navigation, sidebar and top bar#
- The sidebar's workspace links come from
workspaceNavinsrc/data/nav.ts(href,label,key,iconand an optionalbadgesuch as the orders count); the Settings link issettingsNav. Eachiconis the inner markup of a 24×24 stroked SVG, inserted with<Fragment set:html>. - A link gets
aria-current="page"when the page'scurrentprop equals itskey(current="orders"). src/components/Sidebar.astroandsrc/components/Topbar.astroare shared by every app page, so edit them once.
To add a page, create src/pages/reports.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Reports · Northstar Admin" description="Scheduled and saved reports." current="reports">
<main id="main" class="flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-white">Reports</h1>
</main>
</Layout>Then add { href: "/reports", label: "Reports", key: "reports", icon: "<path d=\"...\"/>" } to
workspaceNav, and an entry to the SEARCH list (section 5 of main.ts) so global search finds it.
Always write internal links as href={url("/reports")} (from src/lib/site.ts) so they keep working under
a base path; links built in main.ts use url() too.
Page-level dialogs (the customer panel, the invite and delete modals) go in a
<Fragment slot="after"> inside <Layout>, so they sit outside the app shell like the original markup.
Dark mode#
src/layouts/Layout.astro has a small <script is:inline> in <head> that runs before the page paints.
It applies the saved theme (or, if there isn't one, the system setting) and the saved sidebar state. The
theme toggle ([data-theme-toggle], section 3) switches the dark class on <html> and saves ns-theme
in localStorage. @custom-variant dark in global.css makes Tailwind's dark: classes follow that
class.
- Default to light: in the inline script, remove
|| (!t && matchMedia("(prefers-color-scheme: dark)").matches). - Disable dark mode: remove the toggle buttons (in
Topbar.astro,login.astroand404.astro), the theme lines in the inline script and the@custom-variant darkline inglobal.css.
Features#
Sidebar and mobile drawer#
On wide screens the sidebar can be collapsed to icons; the choice is saved as ns-sidebar. Collapsed
links keep accessible names (aria-label) and tooltips, and the collapse button is then named "Expand
sidebar" (with aria-expanded="false"). Below 1024px the sidebar becomes a drawer with a
backdrop: focus is trapped inside, Escape or the backdrop closes it, and focus returns to the menu button.
Global search#
Ctrl K / ⌘ K or / focuses the search box in the top bar. Results come from SEARCH (pages,
settings sections, every customer, order and product) and are navigable with the arrow keys and Enter.
Results link to filtered pages, such as /orders?q=NS-4821 or /customers?q=Olivia%20Martin.
Menus, modals and toasts#
Dropdown menus (notifications, account) open on click, support the arrow keys, Home and End, and close
with Escape or a click outside. Elements with data-modal are dialogs: data-modal-open="<id>" opens
one, data-modal-close closes it, focus is trapped and returned. toast(message) shows a short status
message; any element with data-toast-msg="..." shows one when clicked.
Charts#
Section 7 contains the chart functions: sparkline(el), lineChart(el, cfg), donut(el, segments, centerLabel), barChart(el, cfg), funnel(el, steps) and heatmap(el). They redraw on resize and
show tooltips on hover. Line charts are also focusable: the Left/Right arrow keys move from point to point
and show each value in the tooltip, and Escape hides it. A line chart takes a LineConfig:
lineChart(el, {
height: 280,
labels: ["Mon", "Tue", "Wed"],
format: money0.format,
series: [{ name: "This period", color: color(1), values: [1200, 1640, 1510], area: true }],
});The overview's range buttons ([data-range="7d" | "30d" | "12m"]) switch the revenue chart between the
three entries of REVENUE.
Orders table#
/orders filters by status tab and search (also from ?q=), sorts by any column header (aria-sort is
updated), paginates 10 rows per page, and supports selecting rows with a bulk-action bar ("Mark as paid",
"Export", "Clear"). Change the page size with per: 10 in the orders state (section 9). The export
button is a demo and only shows a toast.
Customers and products#
/customers filters by segment and search and opens a slide-over detail panel with recent orders and a
notes form. /products filters by category and switches between grid and table views; the choice is
saved as ns-products-view. Stock shows as a meter with "In stock", "Low stock" or "Out of stock".
Settings#
Tabs ([role=tablist][data-tabs="hash"]) support arrow keys, Home and End, and update the URL hash, so
/settings#team and /settings#billing open those tabs. The invite modal adds a member to the list, plan
buttons switch the current plan, notification switches are role="switch" buttons, and the bio field has
a 240-character counter.
Back to top button#
src/scripts/back-to-top.ts adds a .back-to-top button that appears after scrolling about one screen,
returns to the top (instantly with reduced motion), moves focus to the main content, lifts above the
toast and anything marked data-back-to-top-avoid, and hides over pinned elements marked
data-back-to-top-hide. The styles are the .back-to-top rules in global.css. To remove it, delete the
file, its import in Layout.astro and those styles.
Forms#
Forms with data-validate are checked on submit: required fields, email format and minlength. Errors
appear under each field (inside its [data-field] wrapper), linked with aria-describedby, and focus
moves to the first problem. A valid form then calls the handler named by data-on-valid (from the
HANDLERS object) or shows the toast in data-success.
| Form | Page | Behaviour |
|---|---|---|
Sign in (data-on-valid="signin") |
/login |
Email and password (8+ characters); HANDLERS.signin redirects to the overview |
Profile (data-success="Profile saved") |
/settings |
Name, email, bio and other profile fields; shows a toast |
Invite (data-on-valid="invite") |
/settings modal |
Email and role; HANDLERS.invite adds the member to the team list |
Customer note (data-success="Note saved to customer profile") |
/customers panel |
Note text; shows a toast |
Nothing is saved or sent. To connect your API, look for the comment // Demo only: nothing is sent.
in bindForms() (section 6) and send the data before showing the success state:
form.addEventListener("submit", async (e) => {
e.preventDefault();
const ok = fields.map(checkField).every(Boolean);
if (!ok) {
$('[aria-invalid="true"]', form)?.focus();
return;
}
const res = await fetch(form.action, { method: "POST", body: new FormData(form) });
if (!res.ok) return toast("Something went wrong. Please try again.");
const handler = form.dataset.onValid ? HANDLERS[form.dataset.onValid] : undefined;
if (handler) handler(form);
else toast(form.dataset.success || "Saved");
});and give each form an action (for example action="/api/profile"). To handle it with your own code in
this project, add an Astro adapter (for example @astrojs/node or @astrojs/vercel) and an
API endpoint such as src/pages/api/profile.ts with
export const prerender = false. Replace HANDLERS.signin with a call to your authentication provider. A
dashboard should only be reachable after a real sign-in; protect it on your server or host, not in this
front-end code.
Saved data (localStorage)#
| Key | Values | Used for |
|---|---|---|
ns-theme |
"dark" or "light" |
Colour theme |
ns-sidebar |
"collapsed" or "open" |
Desktop sidebar state |
ns-products-view |
"grid" or "table" |
Products page view |
Each value is compared with the exact expected strings when read, so a missing or tampered value falls
back to the default (system theme, open sidebar, grid view). If storage is blocked, everything still
works for the current page. Visitors can reset them by clearing site data, or with
localStorage.removeItem("ns-theme") in the browser console.
SEO and sharing#
- Each page passes
titleanddescriptionto<Layout>, which outputs<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. - The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://dashboard.example.com). Set it to your domain at build time:SITE_URL=https://example.com pnpm build. - The 404 page passes
noindex. A real admin dashboard usually shouldn't be indexed at all: passnoindexfrom every page (or make it the default inLayout.astro) and add apublic/robots.txtwithDisallow: /.
Accessibility#
Built in: a skip link, landmarks, visible focus rings, keyboard support everywhere (drawer, modals and the
customer panel trap focus, menus close with Escape, tabs use arrow keys / Home / End, line charts are
keyboard-readable with tooltips), aria-sort on sortable columns, aria-pressed on toggle buttons,
role="switch" switches, form errors linked with aria-describedby, reduced-motion support, and WCAG AA
contrast in both themes.
When editing, keep one <h1> per page, give new controls labels, and give each chart an aria-label
describing what it shows.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as orders.html rather than
orders/index.html (build.format: "file" in astro.config.mjs), and links point to /orders without a
trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /orders from orders.html
automatically; Vercel does with cleanUrls (below).
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/ordersservesorders.html. - Any static host: upload the contents of
dist/. If the host doesn't map/orderstoorders.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/admin pnpm build. It becomes Astro's base, and every link written with url(),
including search results and table links built in main.ts, includes it. For a real admin area, put it
behind authentication (your app's login, or your host's password protection) and connect the data to your
API.
Tailwind finds class names in src/ automatically, including the ones inside strings in
src/scripts/main.ts, as long as they're written in full. demo/ (if present) is the build used for the
freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and
provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6,
the TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript
7, replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- Charts are blank. Their containers are found by attributes such as
[data-revenue-chart]and#traffic-donut; keep those when editing the pages. - A chart's colours don't change. Edit the
--chart-*variables (both light and dark blocks). - Search doesn't find a new page. Add it to
SEARCHin section 5 ofmain.ts. /settings#billingopens the wrong tab. The tab'saria-controlsmust bepanel-billing.- Dates look stuck in October 2026. The demo uses a fixed
TODAYinsrc/data/demo.ts; set it tonew Date(). - The theme flashes on load. Keep the
<script is:inline>in the layout's<head>; withoutis:inline, Astro would bundle it and run it too late. - Links break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links withurl()rather than plain"/..."strings. - The sidebar stays collapsed. Clear
ns-sidebarfromlocalStorageor click the collapse button.
License#
MIT. The code and artwork are free for personal and commercial use; the download includes the license text
in LICENSE.txt. Inter and JetBrains Mono are licensed under the SIL Open Font License.