Freeframe

Documentation

Dashboard

v1.0.3Updated Oct 18, 2026A polished admin dashboard with live charts, data tables and settings.

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).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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.json

src/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:

css
@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:

ts
{ 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).

  • The sidebar's workspace links come from workspaceNav in src/data/nav.ts (href, label, key, icon and an optional badge such as the orders count); the Settings link is settingsNav. Each icon is the inner markup of a 24×24 stroked SVG, inserted with <Fragment set:html>.
  • A link gets aria-current="page" when the page's current prop equals its key (current="orders").
  • src/components/Sidebar.astro and src/components/Topbar.astro are shared by every app page, so edit them once.

To add a page, create src/pages/reports.astro:

html
---
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.astro and 404.astro), the theme lines in the inline script and the @custom-variant dark line in global.css.

Features#

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.

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.

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:

ts
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:

ts
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 title and description to <Layout>, which outputs <title>, the meta description, og:title, og:description and a summary_large_image Twitter card.
  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://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: pass noindex from every page (or make it the default in Layout.astro) and add a public/robots.txt with Disallow: /.

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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /orders serves orders.html.
  • Any static host: upload the contents of dist/. If the host doesn't map /orders to orders.html, change build.format to "directory" in astro.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 SEARCH in section 5 of main.ts.
  • /settings#billing opens the wrong tab. The tab's aria-controls must be panel-billing.
  • Dates look stuck in October 2026. The demo uses a fixed TODAY in src/data/demo.ts; set it to new Date().
  • The theme flashes on load. Keep the <script is:inline> in the layout's <head>; without is: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 with url() rather than plain "/..." strings.
  • The sidebar stays collapsed. Clear ns-sidebar from localStorage or 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.

Search documentation

Search the guides and every template's documentation