Theming
Retheming is a config edit, not a find-and-replace. Every colour, corner and font on the site
resolves to one --brand-* custom property: a preset sets them all, theme.tokens overrides
any of them, and dark mode is the same tokens under [data-theme=dark]. Components use
semantic utilities only, never a palette name, so no component changes when the brand does.
The free edition keeps the default preset only.
Pick a preset
Section titled “Pick a preset”theme.preset in site.config.ts. default declares every token on :root; the others
override under :root[data-preset=<name>] and inherit the rest, fonts included.
| Preset | Brand | radius / radiusSm |
|---|---|---|
default |
blue, oklch(0.55 0.2 260), white background |
0.75rem / 0.375rem |
warm |
terracotta, oklch(0.555 0.165 40), warm off-white paper |
1rem / 0.5rem |
mono |
near-black, oklch(0.16 0 0), neutral greys |
0 / 0, square |
theme: { preset: 'warm' },Each preset also carries an ogColors block in src/config/presets.ts, in hex, because the OG
renderer cannot read oklch(): change a brand colour and its hex together. A hex
theme.tokens.primary is the one override the images pick up.
Overriding tokens
Section titled “Overriding tokens”theme: { preset: 'warm', tokens: { primary: 'oklch(0.55 0.19 15)', radius: '0.75rem' }, darkTokens: { primary: 'oklch(0.75 0.15 15)' },},Only the tokens you name change; the rest still come from the preset. Base.astro emits them
as an unlayered inline <style>, which outranks every @layer rule in global.css without
!important. Two rules, reported by pnpm check:config with the field path:
- Raw CSS values, not class names.
primary: 'bg-blue-600'fails withsite.config.ts › theme.tokens.primary: must be a CSS value, not a Tailwind class name; anything startingbg-,text-,border-,ring-,fill-,stroke-,font-orrounded-is refused. - No
;,}or<:must not contain ";", "}" or "<", because the value is written straight into a<style>element.
The token names are the keys listed under theme in the
configuration reference. Two of them are easy to misread:
lineis a hairline: card borders, dividers,<hr>.lineStrongis a control boundary: inputs, checkboxes, outline buttons. WCAG 1.4.11 wants a control’s border at 3:1 against every background it sits on; a card border is decoration, and a hairline dark enough for 3:1 boxes every card in. OverridelineStrongwith something pale andpnpm test themefails.radiusis the only corner you set. Cards take 1.5× it (rounded-brand-lg); buttons, chips and badges take 3× (rounded-brand-pill, a pill at any height under 72px). The multiples live insrc/styles/global.css, somono’s0stays square everywhere andwarm’s1remis rounder without a third token.radiusSmis the small logo tile.
Dark mode
Section titled “Dark mode”theme.darkMode |
What happens |
|---|---|
'system' (default) |
follows the OS; the header shows a toggle, and a choice is remembered in localStorage |
'light' / 'dark' |
fixed: data-theme is written on <html> at build time, no toggle and no script |
Under 'system' a short inline script is the first thing in <head>: it reads
localStorage.theme, falls back to matchMedia('(prefers-color-scheme: dark)') and sets
data-theme before the first paint, so there is no flash of the wrong theme. The toggle flips
the attribute and writes the same key. Those two are the only scripts on a content page. The
dark variant is keyed on [data-theme=dark], not on prefers-color-scheme, which is what lets
an explicit choice beat the OS setting.
theme: { font: { provider: 'fontsource', family: 'Inter', weights: [400, 600, 700], preloadWeight: 600 },},The default is { provider: 'system' }: the system UI stack, no font files, no request. With
fontsource, google or bunny, Astro’s font pipeline downloads the family at build time and
self-hosts it, latin subset, normal style, only the weights you list, and preloads
preloadWeight, which must be one of weights or pnpm check:config stops with
theme.font.preloadWeight: must be one of weights [400, 600, 700]. Nothing is fetched from a
third party at runtime. fontDisplay follows font unless you set it.
Icons are lucide, inlined as SVG at build time by astro-icon; nothing is
fetched at runtime. theme.icons is the allow-list of names to bundle, and every
categories[].icon is merged into it for you, so list only the icons your own markup uses; a
name outside the list is not in the bundle. Your own SVGs go in src/icons/: drop
acme-mark.svg there and render it with <Icon name="local:acme-mark" />. The directory ships
with only a README: without it every build prints
[astro-icon] Failed to load icons from "src/icons", which looks like an error and is not.
Add a preset
Section titled “Add a preset”Three files, all required, then the config. The theme.preset enum and the PresetName type
are derived from the registry, so nothing else is edited.
-
Write the CSS.
Copy
src/styles/presets/warm.csstosrc/styles/presets/<name>.cssand scope it to:root[data-preset=<name>]and:root[data-preset=<name>][data-theme=dark]. Every colour and radius token goes in both blocks; the font tokens may inherit. -
Import it, next to the existing presets.
src/styles/global.css @import "./presets/<name>.css"; -
Register it.
src/config/presets.ts { name: '<name>', description: '…', ogColors: { bg: '#…', fg: '#…', primary: '#…', primaryFg: '#…', muted: '#…' } },pnpm checknow accepts the name intheme.preset. -
Use it and test it.
site.config.ts theme: { preset: '<name>' },pnpm test themeasserts the triple and measures the contrast. In the paid starter,pnpm og:defaultthen regeneratespublic/og-default.pngwith the new hex.
The New theme recipe walks an agent through the same steps.
Verify
Section titled “Verify”| Where | You should see | |
|---|---|---|
| The config parses | pnpm check:config |
site.config.ts OK — <your site name> (https://your-domain.com); a bad token fails here with its field path |
| The palette holds up | pnpm test theme |
Test Files 1 passed. The file converts each preset’s OKLCH values to relative luminance and asserts fg and mutedFg at 4.5:1 on bg, surface and muted; primaryFg at 4.5:1 on primary; lineStrong at 3:1 on all three, light and dark |
| The registration | the same run | every preset in PRESETS has its file, its @import and its dark block; the token names in schema.ts and default.css agree |
| The pages | pnpm build, then pnpm preview |
http://localhost:4321/ in the new colours; the header toggle switches without a flash, and a reload keeps the choice |
Configuration
Section titled “Configuration”| Field | Default | What it changes |
|---|---|---|
theme.preset |
'default' |
which preset file and ogColors block the site uses |
theme.tokens, theme.darkTokens |
— | per-token overrides for light and dark, raw CSS values |
theme.darkMode |
'system' |
system, light or dark |
theme.font |
{ provider: 'system' } |
the self-hosted family, its weights and the preloaded one |
theme.icons |
[] |
the lucide names to bundle, on top of the category icons |
theme.viewTransitions |
false |
adds @view-transition{navigation:auto} to every page |
See theme.
Directorysrc/
Directorystyles/
- global.css Tailwind entry, the
@theme inlinemap, the radius multiples, the component classes Directorypresets/ one file per preset;
default.cssis the baseline- …
- theme.ts
themeCss()for the override block and the dark-mode bootstrap script
- global.css Tailwind entry, the
Directoryconfig/
- presets.ts the registry: names, descriptions,
ogColors - schema.ts the token vocabulary and the CSS-value rules
- presets.ts the registry: names, descriptions,
- components/site/ThemeToggle.astro the toggle and its inline script
Directoryicons/ your own SVGs, the
local:collection- …
- tests/unit/theme.test.ts the registration and contrast assertions