Skip to content

Theming

Markdown

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.

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
site.config.ts
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.

site.config.ts
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 with site.config.ts › theme.tokens.primary: must be a CSS value, not a Tailwind class name; anything starting bg-, text-, border-, ring-, fill-, stroke-, font- or rounded- 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:

  • line is a hairline: card borders, dividers, <hr>. lineStrong is 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. Override lineStrong with something pale and pnpm test theme fails.
  • radius is 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 in src/styles/global.css, so mono’s 0 stays square everywhere and warm’s 1rem is rounder without a third token. radiusSm is the small logo tile.
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.

site.config.ts
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.

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.

  1. Write the CSS.

    Copy src/styles/presets/warm.css to src/styles/presets/<name>.css and 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.

  2. Import it, next to the existing presets.

    src/styles/global.css
    @import "./presets/<name>.css";
  3. Register it.

    src/config/presets.ts
    { name: '<name>', description: '', ogColors: { bg: '#…', fg: '#…', primary: '#…', primaryFg: '#…', muted: '#…' } },

    pnpm check now accepts the name in theme.preset.

  4. Use it and test it.

    site.config.ts
    theme: { preset: '<name>' },

    pnpm test theme asserts the triple and measures the contrast. In the paid starter, pnpm og:default then regenerates public/og-default.png with the new hex.

The New theme recipe walks an agent through the same steps.

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
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 inline map, the radius multiples, the component classes
      • Directorypresets/ one file per preset; default.css is the baseline
      • theme.ts themeCss() for the override block and the dark-mode bootstrap script
    • Directoryconfig/
      • presets.ts the registry: names, descriptions, ogColors
      • schema.ts the token vocabulary and the CSS-value rules
    • 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