CSS theming
A theme is a mapping from meaning to value. Components consume meaning ("surface", "accent"); themes redefine the values; nobody touches component CSS to restyle the product.
Method
- Two tiers of tokens. Primitives name values
(
--blue-600: #2563eb); semantics name roles (--color-accent: var(--blue-600)). Components use only semantic tokens. The primitive tier changes when the palette changes; the semantic tier changes when design language changes; components change for neither. - Scope themes to an attribute on :root.
:root { ... }holds the default;:root[data-theme="dark"] { --color-surface: ... }overrides only semantic values. Switching is one attribute flip, inheritable everywhere, no class sprawl. - Name by role, not appearance.
--color-danger, not--color-red: the danger color may not be red in every theme. Scale names (--space-1..8,--text-sm/base/lg) beat value names (--space-4px) because the scale can be retuned. - Keep the semantic set small and complete. Surface, surface-raised, border, text (2-3 strengths), accent, danger/success/warning, focus ring. Every hardcoded hex in component CSS is a future theming bug; lint for raw color values outside the token files.
- Respect the user first paint. Apply the stored theme before CSS
loads (inline script setting
data-theme), default fromprefers-color-scheme, and set thecolor-schemeproperty so form controls and scrollbars match. A white flash before dark mode is a token system applied too late. - Derive sparingly with color functions.
color-mix(in oklch, ...)andoklch()relative colors can generate hover states from base tokens, cutting token count; keep derivations in the token layer so components stay ignorant of the math.
Boundaries
- Tokens do not enforce contrast; themes need their own accessibility pass (see color-contrast).
- Per-component theme overrides (a dark card on a light page) work by re-scoping the semantic tokens on that subtree, not by forking the component's CSS.
- Design-tool token sync (Figma variables to CSS) is a build pipeline concern; hand-editing generated token files breaks the loop.