Definition
This is the approach for managing a site's design values — colors, spacing, border radius — in one place, and switching between light/dark mode or per-brand themes "by changing only the values." To do this, you extract the values into CSS custom properties (variables declared as --token for reuse) and split the application rules with three modern CSS tools: which rule wins is @layer (cascade layers — a feature that divides CSS rules into ordered bundles to set priority), where it applies is @scope (a feature that limits the DOM range where a style takes effect), and which value depends on the mode is light-dark() (a CSS function that picks one of two values based on light/dark). Then theme switching is reduced to just changing token values.
Why it matters
If you hardcode theme colors directly into classes and inline styles all over, you get problems like "why won't this color change?" Multiple rules land on the same element and fight to win (priority conflict), the same value is duplicated in many places, and dark-mode branches proliferate everywhere. The cause is usually buried in specificity (the CSS priority rule where a more specific selector like .a .b wins) battles and is hard to find. If you separate the three questions — "which rule won," "how far does it apply," "what is this mode's value" — into @layer·@scope·light-dark() respectively, you can answer each one independently, making debugging and extension easier.
How it works
| Tool | Role | Caveat |
|---|---|---|
--token: value | Inherited token. Propagates by value swap | Follows inheritance·cascade, so the application boundary matters |
@layer reset, base, theme | Layer order determines priority | Later layers win regardless of specificity |
@scope (root) to (limit) | Limits a token to a subtree | to ([data-theme]) lower bound isolates nested themes |
light-dark(l, d) | Picks one of two values per color-scheme | Encodes a [light, dark] pair in a single property |
Layers declared with @layer get their priority set by declaration order. A later layer always wins, so with reset → base → theme order the theme layer always overrides base. Here the specificity of individual rules does not affect the comparison between layers.
Practical application
If the CSS injected at runtime and the CSS generated at build time are produced separately, the two results diverge and the theme breaks. Have both consumption paths share one generator as the single source of truth (single-source-of-truth-content-metadata), and use atomic writes — write to a temp file then rename — to prevent a half-generated state.
Trade-offs
- Benefit: priority·scope·mode are separated, making debugging·extension easy, and specificity wars disappear. Changing token values in one place switches everything.
- Cost:
@layer/@scope/light-dark()are relatively new features, so if old-browser support is required, fallbacks add cost. You must get the layer-order design right from the start.
When not to use it
- A simple site with only one theme and no dark mode (little benefit from introducing layers·scopes).
- A high share of legacy browsers where the fallback cost for modern CSS features is excessive.
Common mistakes
- Using
light-dark()without settingcolor-scheme, so only the first value ever appears. - Trying to force a theme by raising specificity, which conflicts with the layer model (inter-layer priority cannot be overridden by specificity).
- Splitting the runtime·build CSS generators in two, so the two results differ subtly.
- Omitting the
@scopelower bound (to (...)), leaking tokens into a wider range than intended.
Related concepts
- single-source-of-truth-content-metadata — the principle of keeping generation logic as a single source when multiple paths consume the same output.