AM

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.

AttributeWhat it does
data-toast="…"Shows a toast on click. Notyf loads on first use.
data-tabsWraps data-tab triggers and data-panel panels as an ARIA tablist with arrow-key navigation.
data-wizardMulti-step form. Each step is a data-step, validated before advancing; fields mirror into data-review, and submit swaps to data-wizard-success.
data-filterA select that shows and hides data-filter-item descendants.
data-otpA group of single-character inputs that auto-advance and backspace.
data-boardKanban 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-maskFormats as you type. A pattern (0 digit, a letter, * alphanumeric) or a preset name.
data-mapLeaflet map with markers from a LOCATIONS set. Loaded lazily.
data-lightboxWraps 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-carouselScroll-snap carousel. Add data-autoplay in ms; it stops on hover, focus, or interaction, and never starts under reduced motion.
data-sortableDrag 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-printHidden 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-motion collapses animation and transition durations.
  • Text and icon colours clear WCAG 2.1 AA contrast in both themes — see the -text tokens 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 csp after touching src/partials/head.html.
  • style-src needs 'unsafe-inline'. Colour tints are style attributes so they can use color-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-ancestors is 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 datasetssrc/js/data.js. Keep the shape, replace the values or fetch() them.
  • Module data — the arrays at the top of calendar.js and messages.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.