Themes and design tokens
@theme, semantic tokens, light/dark with CSS variables and a manual toggle.
Updated
What a theme is
A theme is a product's set of visual decisions: colors, fonts, radii, shadows, spacing. In a well-built project, these decisions are centralized in design tokens — named variables — and components use only the tokens.
The result: you change the brand, add dark mode or a second brand from a single file, without touching the components.
Tokens, not hardcoded colors
The classic mistake: bg-blue-600 scattered across 200 files. Change the brand → change 200 files.
The right way: you define semantic design tokens (what role the color plays, not what shade it is) and use them everywhere.
primitive: blue-600, stone-100 → the raw palette
semantic: accent, bg, surface, muted → what the UI uses
@theme — tokens that become classes
@import "tailwindcss";
@theme {
--color-accent: #1f5fbf;
--font-display: "IBM Plex Sans", sans-serif;
--radius-card: 0.75rem;
}Every variable in the right namespace generates classes: --color-accent → bg-accent, text-accent, border-accent/50; --font-display → font-display; --radius-card → rounded-card.
| Namespace | Classes |
|---|---|
--color-* |
bg-*, text-*, border-*, fill-*… |
--font-* |
font-* |
--text-* |
text-* (sizes) |
--spacing |
the base for p-*, m-*, gap-* |
--radius-* |
rounded-* |
--shadow-* |
shadow-* |
--breakpoint-* |
sm:, md:… |
Light / dark with CSS variables
The pattern used in this very app:
:root {
--bg: #f7f6f3;
--fg: #1f1e1c;
--accent: #1f5fbf;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #141414;
--fg: #e9e7e2;
--accent: #6ea4f0;
}
}
@theme inline {
--color-bg: var(--bg);
--color-fg: var(--fg);
--color-accent: var(--accent);
}@theme inline = the class uses var(--bg) directly, so it changes when the variable changes. Components just write bg-bg text-fg — they don't need dark: at all.
A manual toggle (a theme button)
By default, dark: follows the system. For a button that forces the theme:
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
[data-theme=dark] {
--bg: #141414;
--fg: #e9e7e2;
}You put data-theme="dark" on <html> (saved in a cookie/localStorage, applied before hydration so it doesn't flicker).
Light / dark / system, with no "flash"
A real toggle has three options: light, dark and "like the system". The classic problem: the page renders on the server without knowing the user's choice → for a split second it shows in the wrong theme (a flash), then the JS corrects it.
| Where you save the choice | How you avoid the flash |
|---|---|
| a cookie | the server reads the cookie and sets data-theme on <html> directly — zero flash (the page becomes dynamic) |
localStorage |
a small script, inline in <head>, sets data-theme before the page is painted — what the next-themes library does |
/* "system" = no attribute → follows prefers-color-scheme */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@media (prefers-color-scheme: dark) {
:root:not([data-theme=light]) { --bg: #141414; --fg: #e9e7e2; }
}
[data-theme=dark] { --bg: #141414; --fg: #e9e7e2; }Also add color-scheme: light dark to :root — scrollbars and native controls (inputs, selects) adapt automatically.
Several themes / brands
The same mechanism: [data-theme=ocean] { --accent: ... }. The components don't change at all.
Rules
- Components use only semantic tokens (
bg-surface,text-muted), notbg-stone-100. - Few, well-named tokens:
bg,surface,fg,muted,line,accent,success,danger. - Check the contrast in both themes.
Summary
- Semantic tokens (
bg,surface,accent), not raw colors in components. @themecreates classes;@theme inline+ CSS variables = themes that change at runtime.- Dark mode and multiple brands = different values for the same variables.