Themes
A theme owns the page shell — the <html> document, the header/footer, the CSS.
Content stays as Markdown (pages/*.md, posts/*.md); the theme is the code that
wraps it. Same split as a CMS: content is data, the theme is the template.
Three ways to theme — zero-code to a package
- Header & footer partials — drop
pages/_header.htmlandpages/_footer.html; they wrap every page. No JS. - A local theme —
pages/_theme.jsowns the whole document (below). - A published theme — an npm package
volt-theme-<name>; users setTHEME=<name>.
Resolution order: THEME env (.volt/themes/<name> bundled, else volt-theme-<name>
on npm) → local pages/_theme.js → the built-in default.
What a theme module exports
Exactly two things:
// css → served at /_theme.css (shared by pages AND the WYSIWYG editor preview)
export const css = `:root{ /* your default palette */ } body{ … }`;
// layout → returns the full HTML document for one page
export function layout({ title, head, content, meta, nav }) {
return `<!doctype html><html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${title}</title>
${head} <!-- SEO/OG/JSON-LD — always include -->
<link rel="stylesheet" href="/_theme.css" /> <!-- always link this -->
</head><body>
${content} <!-- the rendered page -->
</body></html>`;
}
The layout({ … }) arguments
| Field | What it is |
|---|---|
title |
the page's <title> text (from front-matter, or a fallback) |
head |
pre-built <meta> tags — description, canonical, Open Graph, Twitter, JSON-LD. Render it in <head>. |
content |
the page body, already rendered to HTML |
meta |
the page's front-matter object (e.g. meta.image, custom fields) |
nav |
the configured menu (see Navigation) — an array of { label, href, active } |
Requirements: return a complete <!doctype html> document; render ${head} in
<head>; link /_theme.css; render ${content}.
Colors: the canonical token contract
Set your palette as CSS variables in :root and use var(--…) everywhere. Sticking
to these seven tokens is what makes color schemes and light/dark work for free:
--bg page background
--surface cards / panels / code background
--ink primary text
--muted secondary text
--line borders / dividers
--brand links / accents / buttons
--brand-ink text ON a brand-colored surface (contrast)
:root{
--bg:#ffffff; --surface:#f5f6f8; --ink:#1b1f24; --muted:#666e78;
--line:#d9dde2; --brand:#0b67d6; --brand-ink:#ffffff;
}
a{ color:var(--brand) }
.card{ background:var(--surface); border:1px solid var(--line) }
.btn{ background:var(--brand); color:var(--brand-ink) }
Derive extra tones instead of hard-coding them, so they follow the scheme:
.btn:hover{ background:color-mix(in srgb, var(--brand), #000 16%) }
section.alt{ background:color-mix(in srgb, var(--ink) 4%, var(--bg)) }
Non-color tokens (--radius, spacing) are yours to name — they're structure, not palette.
Color schemes & light/dark — you get these free
The app stamps <html data-scheme="…" data-theme="…"> server-side (no flash) from
SITE_SCHEME and SITE_MODE. The bundled schemes supply light and dark values for
every token. If your theme uses the tokens, it recolors automatically — nothing to
write. One theme × the built-in schemes = many looks.
Optional: give your theme its own dark palette for the no-scheme case:
:root[data-theme="dark"]{ --bg:#0e1116; --ink:#e6e8ee; /* … */ }
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ /* dark */ } }
Navigation (the header menu) + hamburger
The menu is content: pages/_nav.md, a Markdown link list (edit/reorder freely,
external links allowed):
- [Home](/)
- [About](/about)
- [Blog](/blog)
- [Docs ↗](https://docs.example.com)
Your layout receives it as nav — [{ label, href, active }], in order, with
active set for the current page. Render it with the shared classes and the app
supplies the responsive hamburger CSS + active styling:
export function layout({ title, head, content, nav = [] }) {
const links = nav.map(i =>
`<a href="${i.href}"${i.active ? ' class="active"' : ''}>${i.label}</a>`).join("");
const menu = nav.length ? `
<input type="checkbox" id="__navt" class="nav-toggle" hidden />
<label for="__navt" class="nav-burger" aria-label="Menu">☰</label>
<nav class="nav-links">${links}</nav>` : "";
return `… <header><div class="nav-wrap">
<a class="brand" href="/">${process.env.SITE_NAME || "Home"}</a>${menu}
</div></header> …`;
}
Use these class names and it Just Works: .nav-wrap (the header row), .brand,
.nav-links (the links), .nav-toggle + .nav-burger (the checkbox +
hamburger label). Links inherit var(--brand) on hover/active. The menu collapses to a
hamburger under 640px — no JS.
Full-bleed blocks
A block marked class="full-bleed" breaks out of your content column to full viewport
width (a hero image or video). The app appends this utility to every /_theme.css, so
you don't add it — just don't constrain it away, and keep body content in a readable
column.
One stylesheet, page + editor
/_theme.css = your export const css + the scheme definitions + the shared utilities.
Pages link it, and the WYSIWYG editor loads the same /_theme.css, so what an author
sees in the editor matches the published page. Author CSS once, in the theme.
Metadata & OG images
${head} already carries per-page SEO/OG/JSON-LD — just render it. OG image per page via
front-matter (image: /media/og.webp) or site-wide with OG_IMAGE in .env.
Publishing a theme
npx create-volt create-theme my-theme # scaffolds a publishable volt-theme-my-theme
cd volt-theme-my-theme && npm publish
Ship a meta.json with a description — it shows in the config's theme picker. Then in
any app: npm install volt-theme-my-theme and set THEME=my-theme.
Requirements checklist
-
export const css(string) andexport function layout({…}). -
layoutreturns a full<!doctype html>document. - Render
${head}in<head>and link/_theme.css. - Render
${content}. - Colors come from the canonical tokens (so schemes + light/dark work).
- Render
navwith the shared classes + hamburger (recommended). - Responsive; don't fight
.full-bleed. - Publishing? name it
volt-theme-<name>with ameta.jsondescription.