Home / Snippets / Layout /

Theme layer

Use a dedicated @layer for theme overrides to swap design tokens without touching component specificity.

Layer cascade
CSS @layer theming
The theme layer overrides only custom properties — component styles stay untouched. Swap the theme above to see the tokens change.

@layer theme { :root { … } }

Widely Supported
layoutno-js

Quick implementation

/* 1. Declare layer order up front */
@layer reset, base, components, theme;

/* 2. Default tokens in the base layer */
@layer base {
  :root {
    --color-bg:      oklch(0.13 0.02 260);
    --color-surface: oklch(0.19 0.02 260);
    --color-text:    oklch(0.93 0.01 260);
    --color-muted:   oklch(0.63 0.02 260);
    --color-accent:  oklch(0.72 0.19 265);
    --radius:        0.6rem;
  }
}

/* 3. Components use the tokens — never hard-coded values */
@layer components {
  .card {
    background: var(--color-surface);
    color: var(--color-text);
    border-radius: var(--radius);
  }
  .btn-primary {
    background: var(--color-accent);
  }
}

/* 4. Theme layer overrides only the tokens */
@layer theme {
  .theme--brand {
    --color-bg:      oklch(0.18 0.06 320);
    --color-surface: oklch(0.24 0.08 320);
    --color-text:    oklch(0.96 0.01 320);
    --color-accent:  oklch(0.74 0.20 10);
    --radius:        0.25rem;
  }

  @media (prefers-color-scheme: dark) {
    :root {
      --color-bg:      oklch(0.10 0.02 260);
      --color-surface: oklch(0.15 0.02 260);
      --color-accent:  oklch(0.65 0.22 265);
    }
  }
}

Prompt this to your LLM

Includes role, constraints, two framework variants, and edge cases to handle.

You are a senior CSS architect implementing a theming system
for a design system using CSS cascade layers (@layer).

Goal: A theme layer pattern where a dedicated @layer theme sits
at the top of the layer stack and overrides only CSS custom
properties — component styles never change between themes.

Technical constraints:
- Declare all layers at the top of the stylesheet in a single
  @layer statement: @layer reset, base, components, theme;
- Define all design tokens (colors, spacing, radius, etc.) as
  CSS custom properties on :root inside @layer base.
- Component rules inside @layer components must reference only
  var(--token-name) — no hard-coded oklch(), hex, or px values
  for visual properties.
- Theme overrides go entirely inside @layer theme. Each theme
  is a class or data attribute on <html> or <body> that
  re-declares the custom properties that differ.
- Use oklch() for all color values — no hex or rgba.
- A @media (prefers-color-scheme: dark) block inside @layer
  theme handles the OS dark preference as a fallback.

Framework variant (pick one):
A) Vanilla CSS — theme classes like .theme--brand, .theme--dark
   applied to <html>. No JavaScript required for static themes;
   a small toggle script can switch them at runtime.
B) CSS Modules / PostCSS — each theme is a separate .css file
   that wraps its overrides in @layer theme { :root { … } }.
   Import only the active theme file.

Edge cases to handle:
- Layer order is global: if a third-party stylesheet declares
  its own @layer, ensure your @layer statement runs first
  (put it in your entry CSS before any @import).
- Custom properties do not inherit through shadow DOM — document
  that Web Components need their own @layer block or a host
  selector that re-exposes the tokens.
- Specificity inside @layer theme is still subject to normal
  specificity rules; a class selector (.theme--brand) wins over
  :root when both are inside the same layer.

Return CSS only (or CSS Modules structure if variant B).

Why a theme layer beats scoped selectors

The traditional approach to multi-theme CSS is a specificity war. You write .theme-dark .card { background: #111; }, then later discover a component that already has .card.card--elevated { background: #222; }, and the specificity chain breaks. You end up adding !important, which breaks the next override, and eventually theming becomes a maintenance hazard.

A dedicated @layer theme sidesteps specificity entirely. The cascade layers specification says that a rule in a later-declared layer wins over a rule in an earlier layer regardless of selector specificity. Since theme is declared last in @layer reset, base, components, theme;, any token override inside it wins over anything in components — even a deeply nested, high-specificity selector. You never write !important again.

The key insight is that themes should only change what a token's value is, not how a component uses it. If every component already references var(--color-surface) instead of a hard-coded color, swapping the theme layer is the only change needed. The component code is untouched.

How custom properties bridge layers

CSS custom properties (also called CSS variables) are not resolved at parse time — they are resolved at computed value time, per element. This means that when @layer components declares background: var(--color-surface), the browser does not look up --color-surface immediately. It stores the reference and resolves it when computing the element's styles, after all cascade layers have been evaluated.

This lazy resolution is what makes the pattern work. The @layer theme block re-declares --color-surface on a class like .theme--brand. When that class is on <html>, the custom property inherits down to every element. By the time the browser computes the background of a .card, it finds the overridden value — not the base value. The component layer never changes; only the inherited token value does.

This is also why you should define all tokens on :root (or at minimum on the element that ancestors all themed content). Custom properties follow normal CSS inheritance, so a token declared on a parent is available to all descendants. If you scope a token to .card inside @layer base, it won't be reachable from @layer theme without repeating the selector.

Multi-brand theming patterns

For a single product, two or three theme classes in @layer theme are sufficient. For a design system shared across multiple brands, a common pattern is to split each brand into its own CSS file that contains only a single @layer theme { … } block. The build pipeline (or a <link> swap at runtime) loads only the active brand file. Because every file declares the same token names, no component code changes between brands.

A light/dark pair within a brand fits cleanly inside one theme file using a @media (prefers-color-scheme: dark) block nested inside @layer theme. A manual toggle can add a data-color-scheme="dark" attribute to <html> and mirror the media query with an attribute selector in the same layer.

One important boundary: cascade layers are scoped to a stylesheet, but custom properties are scoped to the DOM. Shadow DOM elements do not inherit custom properties across the shadow boundary by default. Web Components that need theming must either re-declare the tokens inside their shadow root or use the :host selector to consume tokens from the light DOM. Document this explicitly — it is the most common source of confusion when applying the theme layer pattern to component libraries.