Theming
ShockStack ships 19 curated themes out of the box. Switching between them is a one-line operation at runtime: set data-theme="<name>" on <html>. Every component reacts instantly because colors flow from design tokens.
The model
┌───────────────────────────────┐
│ <html data-theme="dracula"> │
│ ├── tokens.css (all themes scoped by data-theme)
│ ├── @theme block in global.css
│ └── semantic Tailwind utilities
└───────────────────────────────┘
Three moving parts:
- Tokens —
packages/tokens/dist/tokens.csscontains a block per theme:[data-theme="dracula"] { --ss-color-bg-primary: … }. - Theme attribute —
<html data-theme="midnight">picks which block wins. - Semantic utilities — components never name a color directly; they reach for
bg-bg-primary,text-fg-muted, etc.
The 19 built-in themes
The canonical list lives in frontend/src/lib/themes.ts (VALID_THEMES). DEFAULT_THEME is midnight; the built-in ThemeToggle and PagesMenu components offer the full set.
Resolution order
When the document loads, theme selection follows:
- Saved preference —
localStorage.getItem("theme")if it matches a known theme. - Default theme —
DEFAULT_THEMEfromsrc/lib/themes.ts.
The bootstrap script in BaseLayout.astro runs before paint to prevent a flash of wrong theme.
Don't fight the bootstrap
Any logic that sets the theme on mount has to match what the inline script already set —
otherwise the page will briefly render in one theme and snap to another. If you change the
defaults, update both themes.ts and the inline script it feeds.
Switching themes at runtime
The ThemeToggle.vue component handles persistence and View Transitions animation. If you need to switch imperatively:
import { applyTheme } from "../lib/themes";
applyTheme("tokyo-night"); // sets data-theme + writes localStorage
For a one-off, a DOM update is enough:
document.documentElement.setAttribute("data-theme", "dawn");
localStorage.setItem("theme", "dawn");
Adding a new theme
Add a theme
$ cp packages/tokens/tokens/dracula.json packages/tokens/tokens/my-theme.json
edit my-theme.json with your values
$ pnpm tokens:build
% cp packages/tokens/tokens/dracula.json packages/tokens/tokens/my-theme.json
edit my-theme.json with your values
% pnpm tokens:build
PS C:\> Copy-Item packages/tokens/tokens/dracula.json packages/tokens/tokens/my-theme.json
edit my-theme.json with your values
PS C:\> pnpm tokens:build
Then:
- Register the new theme in
packages/tokens/build.ts(it becomes a[data-theme="my-theme"]block). - Add it to
VALID_THEMESinfrontend/src/lib/themes.tsso the picker knows about it. - Add a preview entry in
THEME_OPTIONSif you want it in the UI switcher.
Accessibility
- Contrast — each semantic token pair (
bg-primary/fg-primary) is tuned to meet WCAG AA in every theme. If you adjust a value, re-test. - Reduced motion — the view-transition animation on theme switch is skipped when
prefers-reduced-motion: reduce. - Focus outlines — derive from
--color-accent-purple, which exists in every theme.
Semantic over literal
In code: always use the semantic utility.
<!-- ✅ Reacts to every theme -->
<div class="bg-bg-secondary text-fg-primary border border-border-default">
<!-- ❌ Locked to one palette -->
<div class="bg-[#1e1e2e] text-[#cdd6f4] border border-[#45475a]">
If you reach for a bracket color, that’s a signal you need a new token.