Theming#
The default site template ships with a small, opinionated design system: OKLCH palette, fluid Utopia type and space scales, system fonts, auto/light/dark themes, an icon set, and a centered hero with subtle background imagery. Out of the box, you get something that looks finished without writing a single line of CSS.
This page covers how the theme is structured and how to override the parts you'll most often want to.
Token model#
Every color is a CSS custom property at one of three layers, each built on the one above:
- Primitives — one neutral ramp.
--color-gray-50through--color-gray-950, plus--color-white/--color-black. This is the only place raw color values live. The default theme uses a pure-neutral gray ramp; change these eleven values and every surface and text color follows — no other file to touch. - Roles — the "brand".
--color-primary,--color-secondary,--color-accent, each with a-fg(text on it) and-hovervariant. The default maps them to grays; point a role at a color ramp (say a red) and every button, link, and focus ring re-skins. Non-gray roles can differ between light and dark. - Semantic — surfaces + text.
--color-bg,--color-surface,--color-fg,--color-border, the callout tokens, etc., mapped onto the ramp. Components reference roles and semantics — never the ramp directly.
Dark mode is the same ramp remapped to reversed steps: no separate dark
color values, just a small block pointing the roles and surfaces at the
opposite end of the gray ramp (--color-bg → a dark gray, --color-fg → a
light gray; elevation inverts, so "lifted" surfaces get lighter). Change the
ramp once and both themes update together.
The token architecture (names, layering, scales) lives in the project's
docs/internal/STYLES.md;
the per-theme color values live in the theme's stylesheet
(templates/default/style.css).
Available themes#
Five bundled page-wide palettes, each with a light and a dark variant (the light/dark/auto mode stays an independent choice). Ovellum is the default; the rest are listed alphabetically, as in the picker:
| Theme | Notes |
|---|---|
| Ovellum | Monochrome, pure-neutral gray ramp (palette: 'default'). The theme this site uses. |
| E-ink | Warm paper + ink black, max-contrast monochrome — like an e-reader. No colored accent; pairs especially well with site.font: 'serif'. |
| Flexoki | Warm inky paper tones, after Flexoki. |
| Nord | Arctic blue-grays — Snow Storm lights, Polar Night darks, Frost accent. |
| Solarized | Ethan Schoonover's base tones; cream light, deep-teal dark. |
Each palette is implemented exactly the way the token model above promises: it
re-skins the same eleven-step gray ramp the roles point at, so the dark variant
comes free from the reversed-ramp remap. Set the server-rendered default with
site.palette; visitors switch at runtime from the
topbar appearance control (persisted in localStorage, applied before paint).
Code-block syntax themes are independent and selectable via
site.codeTheme.
Typography#
Fonts#
Font roles are CSS variables: --font-sans (body + headings), --font-mono
(code), --font-serif (the serif option). The active body font is
--font-body, set from site.font and overridable live by the visitor.
The default ships system-font only — instant first paint, no webfont hop.
Built-in font picker#
site.font takes four values, and the appearance
control exposes the same set as a live Font picker:
| Value | Font | Loads a webfont? |
|---|---|---|
'sans' | System sans-serif stack (default) | No |
'serif' | System serif (Georgia, …) | No |
'inter' | Inter | Yes — bundled, on demand |
'geist' | Geist | Yes — bundled, on demand |
Inter and Geist ship inside the template (served from /assets/fonts/), so
there's nothing to host. Their @font-face rules are lazy by spec: a file is
fetched only when a page actually renders in that family — i.e. when site.font
is set to it, or a visitor picks it. So the default site stays zero-webfont and
fast; the cost is paid only on opt-in. (Code stays monospace either way.)
Both bundled families are under the SIL Open Font License 1.1, which permits redistributing them inside software — so Ovellum can carry them and your build can serve them with no licensing concern.
Bringing your own font#
For a family beyond the bundled two, override the token — it's just a CSS-variable change. Self-hosting is recommended over a Google Fonts link: it avoids the third-party connection and the privacy/GDPR concern of sending visitor IPs to a font CDN, and the old "shared browser cache" argument no longer holds (browsers partition their cache per-site).
Check the license first. Self-hosting means you serve the font file, so only use one whose license permits web embedding. Open-font-licensed (OFL) families — like the bundled Inter and Geist — are always safe. Some "free" fonts are free to embed on your own site but may not be redistributed; those are fine to self-host yourself, the responsibility is just yours.
- Drop the font (and a small stylesheet) into the
publicDir—content/public/is copied to the output root, socontent/public/fonts/…is served at/fonts/…andcontent/public/site.cssat/site.css. - In that stylesheet, referenced from
site.headExtra,@font-faceit and override--font-sans. If your family ships a matching monospace, override--font-monotoo; otherwise leave it on the system stack:
/* content/public/site.css → served at /site.css */
@font-face {
font-family: 'My Font';
src: url('/fonts/my-font.woff2') format('woff2');
font-weight: 100 900; /* variable weight axis */
font-display: swap;
}
:root {
--font-sans: 'My Font', ui-sans-serif, system-ui, sans-serif;
}
<!-- site.headExtra (ovellum.config.*) -->
<link rel="preload" href="/fonts/my-font.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/site.css">
Body, headings, and prose pick up --font-sans automatically. The headExtra
stylesheet loads after the theme's CSS, so its :root override wins; the
preload warms the fetch. (A token override like this overrides the picker
default — your font, not the system stack, becomes the baseline.)
Reading text size#
The appearance control also carries a five-step Text size scale (two steps
smaller, the default in the middle, two larger), rendered as a graduated "A"
ramp — like a Kindle or Safari Reader size stepper. It scales the whole modular
type scale (body and every heading) proportionally via --ov-text-scale,
written to <html data-text-size> and remembered per visitor.
Appearance control#
The palette icon at the right end of the topbar opens a small panel with five controls (inlined into the menu sheet on mobile):
- Mode —
auto(follow the OS viaprefers-color-scheme),light, ordark, written to<html data-theme>. - Theme — one of the five bundled palettes (each with its own line
glyph), written to
<html data-palette>. - Color — the primary color that drives the CTA buttons as well as links, focus rings, and the "On this page" indicator; hover states are mixed from it automatically. Six presets, a native custom-color picker, and a leading Default swatch that returns to the theme's own primary (the dark charcoal in Ovellum).
- Text size — a five-step "A" ramp that scales the reading type, written
to
<html data-text-size>. - Font — Sans-Serif (Default) / Serif / Inter / Geist, written to
<html data-font>; Inter and Geist load on demand (see Fonts above).
Every choice is saved in localStorage and applied before paint, so
revisits never flash the wrong colors, and a visitor's selections follow
them across pages and sessions.
Set the first-visit defaults in config:
{
"site": {
"defaultTheme": "dark",
"palette": "nord",
"accent": "oklch(57% 0.16 255)"
}
}
accent takes any CSS color value and drives the primary + accent roles
until the visitor picks their own. Unset, each theme uses its own primary
(Ovellum's is the monochrome charcoal).
Topbar#
The default topbar is a three-column grid: brand on the left, right-aligned nav, and a controls cluster (search slot + appearance control + mobile menu button).
The brand is the site title by default. Add an optional mark before it
with site.logo (a path to a single-color
SVG/PNG — it renders as a theme-flipping monochrome silhouette); leave it
unset and the title stands alone. The favicon defaults to a root
/favicon.ico, overridable with site.favicon.
Add nav items via site.topbarNav:
{
"site": {
"topbarNav": [
{ "label": "Guides", "href": "/guides/manual-mode/" },
{ "label": "Reference", "href": "/reference/config/" },
{ "label": "GitHub", "href": "https://github.com/you/repo", "external": true }
]
}
}
External links (external: true or any http(s):// URL) open in a new
tab with rel="noopener" and a small external-link icon. Below 720px
the nav collapses into a hamburger that opens a full-width sheet
anchored under the topbar — no extra config required.
Hero#
The landing-page hero (when site.landing.enabled is true) is
centered and gets two stacked background layers, applied via
pseudo-elements so no images ship with the site:
- A 24 px dotted SVG pattern (theme-aware fill, masked to fade at the edges).
- A radial spotlight gradient in your accent color, low alpha.
Hero typography uses clamp() so it scales from mobile to desktop
without a media-query forest. Title max-width is 16 ch; subtitle 56 ch.
Icons#
The template uses Lucide icons throughout —
each one is an inline SVG with stroke="currentColor" and
stroke-width="2", so they pick up colors from the surrounding text
in every theme automatically. No icon font, no separate request.
Available icons in the current bundle:
menu, close, sun, moon, monitor, chevron-down, github,
external-link, search, check.
Adding a new one is one import in packages/site/src/icons.ts and one
entry in the REGISTRY map — the package tree-shakes the rest of
Lucide away, so each icon adds roughly 100 bytes to the bundle.
Lucide v1 dropped brand marks (trademark concerns), so
githubis a hand-rolled exception drawn to match Lucide's stroke language. If you need more brand logos, simple-icons is the standard companion.
Customizing the default theme#
Today, the simplest override is a follow-up stylesheet. Drop a CSS file
in content/ (it passes through as a static asset), then reference it
from your pages or — better — extend the template later via a plugin
system (planned, not built yet).
Re-skin a role — links and accents follow it everywhere (light + dark differ because this is a non-gray color):
:root {
--color-accent: oklch(55% 0.20 320); /* magenta */
--color-accent-fg: var(--color-white);
--color-accent-hover: oklch(48% 0.22 320);
}
:root[data-theme='dark'] {
--color-accent: oklch(72% 0.18 320);
--color-accent-fg: var(--color-gray-950);
--color-accent-hover: oklch(80% 0.16 320);
}
Or re-tone the whole UI by overriding the gray ramp — every surface, text, and (gray) role shifts at once, no per-component edits:
:root {
/* e.g. a warmer 'stone'-style neutral */
--color-gray-100: oklch(97% 0.004 60);
--color-gray-900: oklch(20.5% 0.006 60);
/* …override whichever steps you use */
}
Save as content/css/override.css and reference it from each page's
frontmatter via a future extraStyles field (planned).
The override pattern is still being formalised — for now, expect to fork the default template if you want anything more than color tweaks. Plugin / template-override APIs are on the roadmap.
Theming the landing page#
If you've enabled site.landing, the landing inherits the same tokens.
Hero, feature cards, and trust strip read --color-fg, --color-bg,
--color-accent, and --color-border like every other component. The
hero spotlight tint follows --color-accent automatically, so changing
the accent re-skins the hero atmosphere for free.
Code-block themes#
Code blocks are rendered with shiki at build
time. Each theme is a { light, dark } pair emitted through CSS
variables — the same HTML serves both color schemes; switching
[data-theme] on <html> swaps the palette with zero runtime cost.
Pick one via site.codeTheme:
{
"site": {
"codeTheme": "nord"
}
}
| Value | Light | Dark | Notes |
|---|---|---|---|
'github' | github-light | github-dark | Default. Matches Ovellum's defaults. |
'nord' | min-light | nord | Nord ships dark-only in shiki; paired with min-light for a clean, low-saturation light. |
'solarized' | solarized-light | solarized-dark | Ethan Schoonover's solarized. |
What's bundled today vs. planned#
Available now:
- Default light + default dark.
- Auto-follow-OS via
prefers-color-scheme. - Pre-paint theme script (no flash).
- Lucide-backed icon registry with a
renderIcon(name)helper. - Right-aligned topbar nav with mobile sheet (hamburger below 720 px).
- Centered hero with dotted-noise + accent spotlight background.
- Breadcrumbs above the article on nested pages.
- Per-page meta line (reading time + last-modified) above the article.
- Print stylesheet that strips chrome and widens the article.
- Custom 404 layout (narrower column, larger heading, no chrome).
- Copy buttons on every code block.
Roadmap:
- A
site.themeconfig to switch the page theme by name (Nord, Dracula, …). Each theme ships its own gray ramp + role values plus a reversed-ramp dark block, per the token model. Today only the default page theme ships;site.codeThemealready switches the syntax palette. - A plugin API for fully custom templates.
- Per-page
extraStylesfor one-off page-specific CSS.
Until those land, the recommended path for serious customization is:
- Fork the
templates/default/directory. - Run your own
ovellum.config.tsthat points at your fork. - Re-rebase when Ovellum updates its template.
This is a deliberate constraint for v1 — once the customization surface is stable, an API is easier to commit to.