Freeframe

Documentation

Dashboard

v1.0.1Updated Nov 11, 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 catalog with inventory, settings (profile, team, billing, notifications) and a sign-in screen, in light and dark themes. Charts are SVG Vue components with no chart library. This is the Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS v4 version, version 1.0.1, generated as a static site. See it live at freeframe.dev/templates/dashboard.

Quick start#

Requirements: Node.js 24 or newer and pnpm.

sh
pnpm install
pnpm dev        # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm generate Static site in .output/public/ (every page prerendered to <page>.html)
pnpm build A Node.js server build in .output/ (pages still prerendered)
pnpm preview Serve the last build locally
pnpm lint ESLint (flat config from @nuxt/eslint)
pnpm typecheck nuxt prepare, then type-check with vue-tsc
pnpm build:demo Build for /demos/nuxt/dashboard into demo/ (only used for the freeframe.dev demo)

pnpm install runs nuxt prepare, which writes the types and ESLint config into .nuxt/.

Pages#

Route File What's on it
/ app/pages/index.vue KPI cards with sparklines, revenue chart (7 days / 30 days / 12 months), traffic donut, recent orders, activity, top products
/analytics app/pages/analytics.vue Visitors line chart, revenue by customer type, checkout funnel, order heatmap, top pages, visitors by state
/orders app/pages/orders.vue Orders table: status tabs, search, sorting, pagination, selection and bulk actions
/customers app/pages/customers.vue Customer list with segments and search, slide-over detail panel with a note
/products app/pages/products.vue Catalog with category filter, grid or table view, inventory
/settings app/pages/settings.vue Profile, team (invite dialog), billing, notifications; /settings#billing opens a tab
/login app/pages/login.vue Sign-in screen (demo)
404 app/error.vue Not-found page, generated as 404.html

The app pages use app/layouts/default.vue (sidebar, top bar and footer). The sign-in and 404 pages use app/layouts/blank.vue, which has only the skip link.

Project structure#

dashboard/
├─ app/
│  ├─ app.vue                     <NuxtLayout><NuxtPage /></NuxtLayout>
│  ├─ error.vue                   the 404 page
│  ├─ router.options.ts           keeps query strings written as typed (?q=Olivia%20Martin)
│  ├─ layouts/
│  │  ├─ default.vue              sidebar, phone drawer, top bar (search, theme, menus), footer
│  │  └─ blank.vue                sign-in and 404 pages
│  ├─ pages/                      index, analytics, orders, customers, products, settings, login
│  ├─ components/
│  │  ├─ LineChart.vue, BarChart.vue, DonutChart.vue, FunnelChart.vue, HeatmapChart.vue, SparkLine.vue
│  │  ├─ RevenueCard.vue, VisitorsCard.vue, CustomerPanel.vue, ModalDialog.vue, DropdownMenu.vue
│  │  ├─ GlobalSearch.vue, ThemeToggle.vue, SiteToast.vue, BackToTop.vue
│  │  └─ AppIcon.vue, LogoMark.vue, DotsIcon.vue, StatusBadge.vue, UserAvatar.vue, ProductThumb.vue
│  ├─ composables/
│  │  ├─ useValidation.ts         form validation
│  │  ├─ useToast.ts              the toast message
│  │  ├─ useToday.ts              today (and the copyright year) in the browser
│  │  ├─ useHtmlClass.ts          dark mode / collapsed sidebar state, media queries
│  │  ├─ useWidth.ts              chart container width
│  │  ├─ useQueryParam.ts         ?q= on the orders, customers and products pages
│  │  └─ usePageSeo.ts            per-page title, description, canonical and Open Graph tags
│  ├─ data/
│  │  ├─ demo.ts                  orders, customers, products, chart series, search index
│  │  ├─ nav.ts                   sidebar links, account menu, notifications
│  │  ├─ overview.ts, analytics.ts, customers.ts, settings.ts   page content
│  │  └─ icons.ts, productArt.ts  icon and product artwork paths
│  ├─ utils/                      format.ts (numbers, dates, chart math), ui.ts, dom.ts, safeStorage.ts, url.ts
│  ├─ plugins/                    reveal.client.ts (scroll reveals), stable-build.server.ts
│  └─ assets/
│     ├─ css/main.css             fonts, brand and chart tokens, component classes
│     └─ fonts/                   Inter and JetBrains Mono (Latin .woff2) with their OFL texts
├─ public/img/                    favicon.svg, og-image.png (1200×630) and its og-image.svg source
├─ nuxt.config.ts, eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md

Customizing#

Brand and colors#

app/assets/css/main.css defines the brand-* color scale (indigo by default) in @theme, used for buttons, links, the active navigation item and focus rings. Chart colors are CSS variables set for light mode in :root and again for dark mode in .dark: --chart-1 to --chart-5, --chart-grid, --chart-text, --chart-surface and --heat-0. Charts read them when they paint, so they follow the theme without redrawing.

css
@theme {
  --color-brand-500: #0ea5e9;
  --color-brand-600: #0284c7;
}

The browser theme colors are the two theme-color meta tags in nuxt.config.ts.

Typography#

Inter (400 to 700) and JetBrains Mono (400 and 500) are self-hosted: the @font-face rules at the top of main.css load the Latin .woff2 files in app/assets/fonts/, the same files Google Fonts serves the HTML version, so text sets exactly like it. Their SIL Open Font License texts are the *-OFL.txt files beside them. To change a font, add its .woff2 and license to app/assets/fonts/, add an @font-face rule and update the --font-sans or --font-mono token.

Content#

All demo data lives in app/data/demo.ts:

Export What it is
ANCHOR, shiftDate() The date the demo data is generated around (Oct 3, 2026, UTC), and the helper that moves a demo date forward to today
NAMES, STATUS_TONE Customer names; order status → badge color
PRODUCTS Products (name, SKU, category, price, stock, capacity, units sold, hue, icon)
ORDERS 48 generated orders
CUSTOMERS Customers made from NAMES and their orders, with a Segment
revenue(), dayLabels(), monthLabels() Revenue series for the 7d, 30d and 12m ranges and the chart labels, ending on a given day
TRAFFIC_SOURCES, VISITOR_SERIES, CUSTOMER_TYPE_SERIES, FUNNEL Overview and analytics chart data
heatmapDays(), heatmapTotal() Order heatmap data
SEARCH_INDEX Everything global search can find

Page copy that isn't generated is in the other data files: KPIS and ACTIVITY (overview.ts), SUMMARY, TOP_PAGES and STATES (analytics.ts), CUSTOMER_SUMMARY and SEGMENTS (customers.ts), and MEMBERS, PLANS, INVOICE_AMOUNTS and NOTIFICATION_GROUPS (settings.ts).

Add a product 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 and icon must be values of the Hue and ProductIcon types (the artwork is PRODUCT_ART in app/data/productArt.ts). To use real data, replace these exports with data from your API: fetch it in a page with useFetch() or $fetch().

The demo never goes stale: useToday() (app/composables/useToday.ts) returns the visitor's today once the page has loaded, and every date (charts, orders, the overview greeting, the analytics date range, the plan's renewal date and the invoices) is worked out from it. The generated HTML shows the ANCHOR dates, or neutral text ("Last 30 days", "the 1st of next month"), until then. Preview another day with ?now=YYYY-MM-DD. With real data, drop shiftDate() and show your own dates.

Images and artwork#

There are no photos: avatars are initials on colored circles (UserAvatar.vue, color picked from the name), product images are SVG drawings on tinted tiles (ProductThumb.vue), and charts are SVG. To use product photos, replace ProductThumb with an <img> at a 4:3 ratio (the grid tile) and a square (the table). The favicon is public/img/favicon.svg; the social image is public/img/og-image.png (1200×630), drawn from public/img/og-image.svg.

app/layouts/default.vue holds the sidebar, the phone drawer and the top bar with search, the theme toggle, the notifications menu and the account menu. The links come from app/data/nav.ts: WORKSPACE_NAV, SETTINGS_NAV, ACCOUNT_MENU and NOTIFICATIONS. To add a page:

  1. Create app/pages/<route>.vue with <main id="main" class="flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8"> as its root and a usePageSeo({ title, description }) call.
  2. Add it to WORKSPACE_NAV (the icon name is a key of ICONS in app/data/icons.ts).
  3. Add the route to routes in nuxt.config.ts so nuxt generate writes it, and to SEARCH_INDEX.

Dark mode#

bootScript in nuxt.config.ts runs in <head> and applies the saved theme (ns-theme) and sidebar state (ns-sidebar) before the first paint, so there's no flash. ThemeToggle.vue flips the dark class on <html> and saves the choice; Tailwind's dark: variant follows the class (@custom-variant dark in main.css). To default to light, remove ||(!t&&matchMedia('(prefers-color-scheme: dark)').matches) from bootScript.

Features#

On wide screens the sidebar collapses to icons (saved as ns-sidebar); collapsed links keep accessible names and tooltips, and the collapse button becomes "Expand sidebar". On small screens it's a drawer: focus moves into it and is trapped, Escape or the backdrop closes it, and focus returns to the menu button. It also closes when a link opens another page or the window grows to desktop width.

Ctrl K / ⌘ K or / focuses search (GlobalSearch.vue). Results come from SEARCH_INDEX, show up to eight matches and are navigable with the arrow keys and Enter; Escape clears the field. Entries link to filtered pages such as /orders?q=NS-4821, which read the query with useQueryParam() after the page has loaded and when a link opens the same page with another value.

DropdownMenu.vue focuses the first item when it opens, moves with the arrow keys, Home and End, and closes with Escape (returning focus to its button), Tab or a click outside. ModalDialog.vue is appended to <body> while open, traps focus and returns it. useToast() gives a toast(message) function; the message shows for 2.8 seconds in SiteToast.vue, a polite live region. Buttons for screens the template doesn't include (help center, new customer, CSV import, photo upload and so on) show an in-app message such as "Opening the help center…" or "Exporting your report. The PDF will arrive by email."; point them at your own pages or actions and change the messages to match.

Charts#

LineChart, BarChart, DonutChart, FunnelChart, HeatmapChart and SparkLine are responsive SVG drawn from props. The width-dependent charts draw after the page has loaded at their container's width (useWidth()) and redraw when it changes. LineChart is keyboard-accessible: focus it and use the left and right arrow keys to move between points, Escape to hide the tooltip.

<LineChart
  label="Revenue, last 30 days"
  :labels="labels"
  :series="[{ name: 'This period', color: 'var(--chart-1)', values, area: true }]"
  :format="(v) => money0.format(v)"
/>

Each series needs as many values as there are labels. VisitorsCard.vue hides a line when its legend button is pressed (one line always stays), and DonutChart highlights a segment on hover or when its legend row has focus.

Orders table#

app/pages/orders.vue has status tabs with counts (and the same filter as a select), search (also from ?q=), sortable columns with aria-sort, PER_PAGE (10) rows per page, row selection with a select-all box, and a bulk-action bar (mark as paid, export, clear). Export only shows a toast ("Exporting 3 orders. The CSV will arrive by email."); connect it to your own export.

Customers and products#

app/pages/customers.vue filters by segment and search and opens CustomerPanel.vue, a slide-over dialog with the customer's numbers, recent orders and a validated note form; Escape, the backdrop or Close shut it and focus returns to the row. app/pages/products.vue filters by category and search and switches between a grid and a table, remembered as ns-products-view. "Restock" shows "Restock request TC-7K2Q created for …" with a short reference made in the browser by makeRef() in app/utils/ui.ts; use your purchasing system's own reference if it has one.

Settings#

app/pages/settings.vue keeps the selected tab in the URL hash (/settings#team, /settings#billing) and follows links to another tab. Tabs support the arrow keys, Home and End. It includes the profile form with a bio counter, the team list with an invite dialog and remove buttons, plan cards with a switch button, invoices dated from today and notification switches (role="switch").

Back to top button#

BackToTop.vue (appended to <body> by both layouts) appears after scrolling about one screen, returns to the top (instantly with reduced motion), moves focus to #main, lifts above a visible toast (anything matching .toast or [data-back-to-top-avoid]), steps aside while an in-page "Back to top" link or an element marked data-back-to-top-hide is in the way, and is hidden when printing. Its styles are .back-to-top in main.css. Remove <BackToTop /> from the layouts and those styles to drop it.

Forms#

Forms use useValidation() from app/composables/useValidation.ts:

<script setup lang="ts">
const form = useValidation();
const toast = useToast();
</script>

<template>
  <form novalidate @submit="form.submit($event, () => toast('Profile saved'))">
    <input type="email" class="field" v-bind="form.field('profile-email', { label: 'Email address', required: true, email: true })">
    <p v-if="form.errors['profile-email']" id="profile-email-error" class="field-error">{{ form.errors["profile-email"] }}</p>
  </form>
</template>

field(id, rules) returns the attributes for an input (id, name, required, aria-invalid, aria-describedby and the blur and input checks); rules are label, required, email and minLength. submit(event, onValid) checks every field, focuses the first invalid one, and otherwise calls onValid(formElement).

Form File Behavior
Sign in app/pages/login.vue Email and password (8+ characters, with a Show/Hide button), then opens /
Profile app/pages/settings.vue Name and email; "Profile saved" toast; Cancel resets the form
Invite member app/pages/settings.vue (dialog) Email and role; adds the member to the team list and shows "Invitation sent to …"
Customer note app/components/CustomerPanel.vue Note text; "Note saved to customer profile" toast

Nothing is saved or sent until you connect it, although the messages read as if it were ("Invitation sent to …", "Plan changed to Scale. The new price starts on your next invoice."). To save for real, post the data in onValid, for example to a form service such as Formspree or to your own API (@submit="form.submit($event, saveProfile)"):

ts
async function saveProfile(el: HTMLFormElement) {
  try {
    await $fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(el), headers: { Accept: "application/json" } });
    toast("Profile saved");
  } catch {
    toast("Something went wrong. Try again.");
  }
}

To write the API inside this project, add server routes in server/api/ (for example server/api/profile.post.ts) and deploy with pnpm build to a host that runs Node.js; nuxt generate alone produces static files only. Replace the timeout in signIn() (login.vue) with your authentication provider, and protect the dashboard routes on the server (for example with route middleware that checks a session); hiding a link isn't protection.

Saved data (localStorage)#

Key Values Used for
ns-theme "dark" or "light" Color theme
ns-sidebar "collapsed" or "open" Desktop sidebar state
ns-products-view "grid" or "table" Products page view

The keys are listed in STORAGE_KEYS (app/utils/safeStorage.ts). Values are 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). safeStorage keeps choices in memory when the browser blocks storage or it is full, so they still last for the page view. To reset, remove the keys in the browser's developer tools (localStorage.removeItem("ns-products-view")).

SEO and sharing#

  • Every page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title (Orders · Northstar Admin), description, canonical URL, Open Graph tags and the Twitter card. The 404 page passes noindex: true.
  • URLs are absolute: runtimeConfig.public.siteUrl in nuxt.config.ts reads SITE_URL (default https://northstar.example). Set it when you build: SITE_URL=https://admin.example.com pnpm generate.
  • An admin area usually shouldn't be indexed: pass noindex: true on every page and add a public/robots.txt with Disallow: /.

Accessibility#

Built in: a skip link, landmarks, visible focus rings, a focus-trapped drawer, dialogs and customer panel, menus that close with Escape, tabs with the arrow keys, Home and End, keyboard-readable line charts, aria-sort on sortable columns, aria-pressed toggles and role="switch" switches, form errors linked with aria-describedby, a polite live region for toasts, reduced-motion support (no reveals or slide animations), and WCAG AA contrast in both themes.

When editing, keep one <h1> per page, label every control, and give each LineChart a descriptive label.

Building and deploying#

sh
pnpm generate   # static site in .output/public
pnpm preview    # serve it locally

Deploy .output/public to any static host (Netlify, Cloudflare Pages, GitHub Pages, Vercel, an S3 bucket). Every page is a file such as orders.html; hosts that serve /orders from orders.html (most do) give clean URLs, and 404.html is the not-found page. For a sub-folder, set DEMO_BASE_PATH (DEMO_BASE_PATH=/admin pnpm generate), which becomes Nuxt's app.baseURL. For a real admin area with server routes and authentication, use pnpm build and a host that runs Node.js. demo/, if present, is the freeframe.dev demo build and can be deleted.

Troubleshooting#

  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install (or pnpm typecheck) first: nuxt prepare writes it.
  • A new page is missing from .output/public. Add its route to routes in nuxt.config.ts.
  • Social previews have no image. Build with SITE_URL set to your domain.
  • A chart is blank after you change its data. Every series needs as many values as there are labels.
  • The sidebar stays collapsed. Clear ns-sidebar from localStorage or click the expand button.
  • Dates show October 2026 with JavaScript off. That is the generated HTML's ANCHOR; in the browser they follow today (useToday()).
  • A hydration warning after you add a date or a stored setting. Read the clock, the URL and localStorage in onMounted() or an event handler, never while rendering.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Inter and JetBrains Mono are licensed under the SIL Open Font License. Business, customer and personal details are fictional.

Search documentation

Search the guides and every template's documentation