skip to content

theme system

multi-theme architecture with css variables
published:
0 views

what is this?

the site supports 5 color themes and 3 typography options. themes are implemented with css custom properties, no javascript theme-specific styles. switching is instant, no flash of wrong theme (fouc).

available themes

nametypebase palette
hollow knightdarkcatppuccin mocha (yellow primary)
radiantlightcatppuccin latte (yellow primary)
tokyo nightdarktokyo night colors (cyan accent)
draculadarkdracula palette (pink accent)
one darkdarkatom one dark (blue accent)

typography options: mono (jetbrains mono), sans (inter), serif (merriweather)

architecture

src/styles/
├── themes/
│ ├── hollowknight.css # --hk-* raw palette
│ ├── radiant.css # --rad-* raw palette
│ ├── tokyo-night.css # --tn-* raw palette
│ ├── dracula.css # --drac-* raw palette
│ └── onedark.css # --od-* raw palette
├── tokens.css # semantic tokens + theme selectors
└── global.css # imports everything

each theme file defines raw palette variables. tokens.css maps them to semantic tokens.

theme file structure

theme files only contain raw color values:

/* hollowknight.css - catppuccin mocha */
:root {
--hk-base: #1e1e2e;
--hk-text: #cdd6f4;
--hk-yellow: #f9e2af;
--hk-blue: #89b4fa;
/* ... */
}

prefixes: --hk- (hollowknight), --rad- (radiant), --tn- (tokyo-night), --drac- (dracula), --od- (onedark)

semantic tokens

tokens.css defines semantic tokens that components use:

@theme {
/* fallback (radiant/light as base) */
--color-global-bg: var(--rad-base);
--color-global-text: var(--rad-text);
--color-primary: var(--rad-yellow);
--color-accent: var(--rad-blue);
--color-link: var(--rad-sky);
--color-surface-0: var(--rad-base);
--color-surface-1: var(--rad-mantle);
--color-surface-2: var(--rad-surface0);
--color-border-default: var(--rad-surface1);
/* ... */
}

then per-theme overrides:

html[data-theme="hollowknight"] {
--color-global-bg: var(--hk-base);
--color-global-text: var(--hk-text);
--color-primary: var(--hk-yellow);
/* ... */
}
html[data-theme="tokyo-night"] {
--color-global-bg: var(--tn-base);
--color-primary: var(--tn-cyan);
/* ... */
}

theme provider

ThemeProvider.astro handles fouc prevention and persistence:

<script is:inline>
(function() {
const VALID_THEMES = ["hollowknight", "radiant", "tokyo-night", "dracula", "onedark"];
const stored = localStorage.getItem("theme");
if (stored && VALID_THEMES.includes(stored)) {
document.documentElement.dataset.theme = stored;
} else {
// system preference fallback
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.dataset.theme = prefersDark ? "hollowknight" : "radiant";
}
})();
</script>

this runs before paint (inline in <head>), preventing flash.

typography switching

typography is simpler - just override --font-body:

html[data-typography="mono"] {
--font-body: var(--font-mono);
}
html[data-typography="sans"] {
--font-body: var(--font-sans);
}
html[data-typography="serif"] {
--font-body: var(--font-serif);
}

theme panel component

ThemePanel.astro provides the dropdown ui. stores selections in localStorage:

localStorage.setItem("theme", "tokyo-night");
localStorage.setItem("typography", "sans");

changes apply immediately by updating data-theme and data-typography attributes on <html>.

expressive code integration

code blocks use catppuccin themes. the css selector maps all dark themes to mocha, light to latte:

site.config.ts
const darkThemes = new Set([
"hollowknight",
"tokyo-night",
"dracula",
"onedark",
]);
export const expressiveCodeOptions = {
themeCssSelector(theme) {
const isDarkTheme = theme.type === "dark";
return isDarkTheme
? [...darkThemes].map((t) => `[data-theme="${t}"]`).join(", ")
: `[data-theme="radiant"]`;
},
themes: ["catppuccin-mocha", "catppuccin-latte"],
};

adding a new theme

  1. create src/styles/themes/{name}.css with raw palette
  2. add theme info to themeConfig in site.config.ts
  3. add ThemeName type in types.ts
  4. add html[data-theme="{name}"] selector in tokens.css
  5. add to VALID_THEMES in ThemeProvider.astro
  6. if dark, add to darkThemes set for expressive-code
  7. if dark, add to color-scheme selector in global.css

types

types.ts
type ThemeName =
| "hollowknight"
| "radiant"
| "tokyo-night"
| "dracula"
| "onedark";
type Typography = "mono" | "sans" | "serif";
interface ThemeInfo {
name: ThemeName;
label: string;
isDark: boolean;
}
interface ThemeConfig {
themes: ThemeInfo[];
defaultDark: ThemeName;
defaultLight: ThemeName;
typography: Typography[];
defaultTypography: Typography;
}

config

site.config.ts
export const themeConfig: ThemeConfig = {
themes: [
{ name: "hollowknight", label: "hollow knight", isDark: true },
{ name: "radiant", label: "radiant", isDark: false },
{ name: "tokyo-night", label: "tokyo night", isDark: true },
{ name: "dracula", label: "dracula", isDark: true },
{ name: "onedark", label: "one dark", isDark: true },
],
defaultDark: "hollowknight",
defaultLight: "radiant",
typography: ["mono", "sans", "serif"],
defaultTypography: "mono",
};