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

  1. Header & footer partials — drop pages/_header.html and pages/_footer.html; they wrap every page. No JS.
  2. A local themepages/_theme.js owns the whole document (below).
  3. A published theme — an npm package volt-theme-<name>; users set THEME=<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) and export function layout({…}).
  • layout returns 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 nav with the shared classes + hamburger (recommended).
  • Responsive; don't fight .full-bleed.
  • Publishing? name it volt-theme-<name> with a meta.json description.