Documentation
How this template is built, and how to make it yours.
Getting started
Node 20 or newer. Everything is a static build — there is no server component.
npm install
npm run dev # http://localhost:5173
npm run build # static site in dist/
npm run preview # serve the built site
npm run check # sweep every page in both themes (see Accessibility)
npm run csp # print a Content-Security-Policy for this build
The build emits plain HTML, CSS, and JS. Host it anywhere that serves files — Netlify, Vercel, Cloudflare Pages, S3, GitHub Pages, or nginx.
Project structure
*.html every page is a root .html file
src/
partials/ head, sidebar, header, footer, scripts
styles/
main.css Tailwind v4 + OKLCh design tokens
js/
main.js entry point — boots each feature module
data.js chart datasets
modules/ theme, preset, color, sidebar, charts, sparkline,
datatables, datepicker, mask, map, lightbox,
carousel, sortable, command, interactions,
calendar, board, messages, settings, icons
public/ static assets copied verbatim
vite.config.js page discovery + partial directory
Vite discovers every .html file in the project root automatically. To add a page, copy an existing one — no configuration change is needed.
Theming
Every colour is an OKLCh custom property in src/styles/main.css. Change the accent in one place and it flows through buttons, links, focus rings, charts, and the sidebar.
:root {
--primary: oklch(0.589 0.240 293); /* violet */
--background: oklch(0.977 0.005 275);
--status-active: oklch(0.605 0.145 150);
--chart-1: oklch(0.589 0.240 293);
}
.dark {
--primary: oklch(0.680 0.200 293);
--background: oklch(0.168 0.024 285);
}
Dark mode is a .dark class on <html>. The inline script in src/partials/head.html applies the saved theme, accent, density, and sidebar state before first paint, so there is never a flash of the wrong theme.
Every accent, chart, and status colour also has a -text twin. Fills want a light, vivid colour; the same colour set as small text on a pale tint of itself — the badge and avatar pattern used all over this template — only reaches about 3.5:1. The twins are the same hue and chroma with the lightness solved so that worst case still clears WCAG AA.
<!-- fill from the base token, text from the twin -->
<span class="rounded-full px-2 py-0.5 text-xs"
style="background-color:color-mix(in oklch, var(--status-active) 14%, transparent);
color:var(--status-active-text)">Active</span>
<!-- as utilities -->
<span class="bg-primary/10 text-primary-text">AO</span>
Rule of thumb: base token for fills, borders, and chart series; -text twin for anything with a glyph in it, text or icon. Retune a base colour and you should retune its twin the same way.
Accent presets live in src/js/modules/preset.js as [hue, chroma, textLightness light, textLightness dark] — the fill lightness is chosen per mode so the accent stays legible on both the white and the deep-violet surface, and the last two keep the twin honest. If you add a preset, mirror it in the head script.
Charts
Charts are declarative. Add a data-chart element and point it at a dataset in src/js/data.js.
<div data-chart="area" data-series="userGrowth" data-height="290"></div>
<div data-chart="column" data-series="signupsChurn"></div>
<div data-chart="donut" data-series="usersByRole"></div>
<div data-chart="bar" data-series="usersByTeam"></div>
<div data-chart="radial" data-series="mfaAdoption"></div>
<div data-chart="line" data-series="activeUsers"></div>
<div data-chart="heatmap" data-series="engagementHeat"></div>
ApexCharts is loaded lazily, so pages without charts never download it. Charts read the CSS custom properties directly and re-render on theme change, which keeps them in step with the accent picker.
For a single number, use an inline SVG sparkline instead — no library at all:
<span data-sparkline="12,15,18,14,22" data-color="chart-1" data-area></span>
Maps
Maps use Leaflet, loaded lazily so only map pages pay for it. Point one at a location set in src/js/data.js:
<div data-map data-locations="signIns" data-height="420"></div>
export const LOCATIONS = {
signIns: {
center: [30, 15],
zoom: 2,
points: [
{ lat: 51.5074, lng: -0.1278, label: "London, UK",
detail: "412 sessions · 318 users", weight: 412, tone: "low" },
],
},
};
Marker area scales with weight, and tone picks a status colour (low, medium, high, critical). Markers are redrawn on theme change so they track the accent.
Tiles are fetched from OpenStreetMap — the one place this template makes an external request. Attribution is required and is rendered by Leaflet. Swap the tile URL in src/js/modules/map.js for your own provider if you would rather not depend on it. Dark mode inverts the tiles with a CSS filter rather than pulling a second basemap.
Media & arrangement
Lightbox
Built on a native <dialog>, so focus trapping and the Escape key come from the platform rather than being re-implemented.
<div data-lightbox>
<a href="/full.svg" data-lb-item data-caption="Primary logo" data-meta="SVG · 12 KB">
<img src="/full.svg" alt="Primary logo" />
</a>
</div>
The item list is rebuilt each time it opens, so a filtered grid pages through only what is on screen. See it on Brand Assets.
Carousel
CSS scroll-snap does the sliding — touch swipe, trackpad, and scrollbar dragging all work with no JavaScript. The module adds arrows, dots, keyboard paging, and optional autoplay.
<div data-carousel data-autoplay="7000">
<div data-carousel-track class="no-scrollbar flex snap-x snap-mandatory gap-4 overflow-x-auto">
<article data-carousel-slide class="shrink-0 snap-center">…</article>
</div>
<button data-carousel-prev>…</button>
<button data-carousel-next>…</button>
<div data-carousel-dots></div>
<span data-carousel-status></span>
</div>
Slide width is yours to set — make slides full width for one at a time, or a fraction to show several. The counter tracks the leading slide, and paging loops at both ends.
Sortable
Drag-to-reorder for grids and lists, with a keyboard path and persistence. The attribute value is the localStorage key; pass an empty value to skip saving.
<div data-sortable="overview-kpis">
<div data-sort-item="kpi-users" data-sort-label="Total users">
<button data-sort-handle>⠿</button>
</div>
</div>
<button data-sort-reset="overview-kpis">Reset order</button>
Without a data-sort-handle the whole item is draggable. Moves are announced to a live region, so the keyboard path is usable with a screen reader. See it on the Overview KPI row and the Dashboard Builder.
Data tables
Add data-datatable to any table and it gains search, click-to-sort, pagination, faceted filters, column visibility, and CSV export. No table library is involved — it is about 200 lines of vanilla JS.
<table
data-datatable
data-search="Search users…"
data-per-page="10"
data-facets="Role,Team,Status"
data-export="users"
>
Facet names must match the header text exactly. Sorting detects dates and numbers automatically and falls back to text. The table markup you write is the source of truth — swap the rows for your own and everything still works.
Interaction hooks
Small behaviours are wired by data attributes rather than per-page scripts.
| Attribute | What it does |
|---|---|
data-toast="…" | Shows a toast on click. Notyf loads on first use. |
data-tabs | Wraps data-tab triggers and data-panel panels as an ARIA tablist with arrow-key navigation. |
data-wizard | Multi-step form. Each step is a data-step, validated before advancing; fields mirror into data-review, and submit swaps to data-wizard-success. |
data-filter | A select that shows and hides data-filter-item descendants. |
data-otp | A group of single-character inputs that auto-advance and backspace. |
data-board | Kanban drag-and-drop plus a keyboard path (⌘/Ctrl + ←/→). |
data-datepicker | "single" or "range". Add data-presets for shortcuts, data-name to write into a form, data-align to flip the panel. In-house — no date library. |
data-mask | Formats as you type. A pattern (0 digit, a letter, * alphanumeric) or a preset name. |
data-map | Leaflet map with markers from a LOCATIONS set. Loaded lazily. |
data-lightbox | Wraps a grid of data-lb-item links. Opens a native <dialog> with arrow-key paging; only visible items are paged, so it respects an active filter. |
data-carousel | Scroll-snap carousel. Add data-autoplay in ms; it stops on hover, focus, or interaction, and never starts under reduced motion. |
data-sortable | Drag to reorder a grid or list. The value is a localStorage key; children need a stable data-sort-item id. ⌘/Ctrl + arrows work too. |
data-no-print | Hidden when the page is printed. Used on the invoice. |
The .switch class turns a visually-hidden checkbox into a toggle: pair <input class="switch-input sr-only"> with <span class="switch">.
Adding a page
Copy this skeleton to a new .html file in the project root and add a link in src/partials/sidebar.html. Add an entry to COMMANDS in src/js/modules/command.js so ⌘K finds it too.
<!doctype html>
<html lang="en">
<head>
<title>My page · User Management</title>
{{> head }}
</head>
<body class="min-h-screen">
{{> sidebar }}
<div data-app-shell class="flex min-h-screen min-w-0 flex-col lg:ms-[264px]">
{{> header }}
<main id="main-content" tabindex="-1" class="min-w-0 flex-1 p-4 sm:p-6">
<!-- your content -->
</main>
{{> footer }}
</div>
{{> scripts }}
</body>
</html>
The active sidebar link is derived from the filename at runtime, so nothing else needs updating.
Accessibility
- A skip link on every page jumps straight to
#main-content. - Tabs are a real ARIA tablist with roving tabindex and arrow-key navigation.
- The kanban board is operable from the keyboard and announces moves to a live region.
- Icon-only buttons carry
aria-label; status is never conveyed by colour alone. - Focus rings are visible and unlayered, so utility classes cannot suppress them.
prefers-reduced-motioncollapses animation and transition durations.- Text and icon colours clear WCAG 2.1 AA contrast in both themes — see the
-texttokens under Theming.
There is a harness for this. npm run check builds the site, then opens every page in light and dark and fails on a console error, a failed request, horizontal overflow at 360px, an icon name that is not in the curated set, a missing title or lang, a broken internal link, a bundle over budget, or any serious or critical axe violation.
npm run check # everything, both themes
npm run check -- --fast # skip the accessibility pass
npm run check -- --page=users.html
It drives your installed Chrome through playwright-core, so there is no browser download. Budgets and the theme storage key are configured in the check block of package.json. Run it after you swap in your own content — it is the fastest way to catch a colour pairing that no longer passes.
Security headers
The site is static, but it does carry one inline <script> — the pre-paint theme applier. It has to be inline; a separate file would load after first paint and you would see a flash of the wrong theme. A strict Content-Security-Policy therefore wants its hash rather than 'unsafe-inline'.
npm run csp reads the hash out of dist/ and prints a policy this build satisfies. Add -- --header for a single line you can paste into a server config.
default-src 'self';
script-src 'self' 'sha256-lSTpPc9mG7fdlkFZmxofBF87WXoWYP8ofXs/UPZYQKw=';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://tile.openstreetmap.org;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
- The script hash changes when you edit the pre-paint script. Re-run
npm run cspafter touchingsrc/partials/head.html. style-srcneeds'unsafe-inline'. Colour tints arestyleattributes so they can usecolor-mix()with the tokens, and ApexCharts and Notyf each inject a<style>element at runtime. None of it interpolates user input.connect-src 'self'holds only because the template makes no network calls of its own. Widen it once the pages talk to your API.frame-ancestorsis ignored inside a<meta http-equiv>tag — it only works as a real response header.
Wiring in your own data
Everything shipped here is fictional demo content, and it lives in three places:
- Chart datasets —
src/js/data.js. Keep the shape, replace the values orfetch()them. - Module data — the arrays at the top of
calendar.jsandmessages.js. - Table and list rows — written directly in the page markup, so your template engine can render them server-side.
This is a front-end template. There is no authentication, authorisation, or persistence behind these screens — the permission matrix describes an interface, not an enforcement layer. Wire it to a backend that enforces access on the server before any production use.