Container Style Query Theming: One Token Instead of a Variant Class Matrix
A themable panel usually acquires a class for every theme it supports, and every rule inside the panel acquires a compound selector for every one of those classes. Add a second axis — density, brand, emphasis — and the rule count multiplies rather than adds. Style queries collapse that: the panel gets no theme class at all, one --theme token is set anywhere above it, and one block per theme value restyles the whole subtree. This page belongs to the style queries and container state guide within Mastering Container Queries & Responsive Layouts, and assumes the matching rules covered in style queries with custom properties.
Why a token beats a class here
The class approach is not wrong, it is just structurally expensive. .panel--dusk .panel__title names both the theme and the part, so the theme name is baked into every selector that responds to it. Renaming a theme means a sweep of the stylesheet; adding one means duplicating a block per themed part. Worse, the compound selector outranks the base rule by construction, so the base rule can never be adjusted without checking what the theme selectors are doing to it.
The data- attribute approach — [data-theme="dusk"] .panel__title — reads better and can be toggled from script cheaply, but it has the same shape. It is still a descendant selector, still specificity-heavier than the base rule, and still requires the attribute to sit at a known ancestor that every rule can reach through the combinator.
A token changes what is coupled. The theme value travels by inheritance, not by selector matching, so the selectors inside the theme block are the same selectors as the base rules — .panel__title, not .something .panel__title. Equal specificity, resolved by source order, which is the cascade behaving the way you would explain it to someone learning it.
There is a real cost to weigh: a token is invisible in the DOM inspector's markup view and cannot be targeted by a selector, so debugging means reading computed styles rather than scanning attributes. And on a browser version predating style queries an attribute still matches while the query block is dropped, so the token approach needs its base rules to stand on their own.
The token is set once on an outer region in the demo below; the panel inside carries no theme class of its own.
Complete working implementation
A settings panel that themes from an ancestor token, with the user's colour-scheme preference feeding the same token so there is exactly one place a theme is decided.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Token-themed panel</title>
<style>
/* The token's only job is to name a theme. It carries no colours itself, so a
region can switch theme without knowing what any component does with it. */
:root { --theme: day; }
/* One rule maps the OS preference onto the token. Every style query below
responds without being duplicated inside a media query. */
@media (prefers-color-scheme: dark) {
:root { --theme: dusk; }
}
/* Any region can override for its own subtree — no selector changes required. */
.region--dusk { --theme: dusk; }
.region--day { --theme: day; }
body { font: 400 16px/1.5 system-ui, sans-serif; margin: 0; padding: 2rem; }
.panel {
max-inline-size: 32rem;
padding: 1.25rem;
border: 1px solid #d0d5dd;
border-radius: 12px;
background: #ffffff;
color: #101828;
}
.panel__title { margin: 0 0 .4rem; font: 600 1.05rem/1.3 system-ui, sans-serif; }
.panel__body { margin: 0 0 1rem; color: #667085; }
.panel__action {
padding: .5rem 1rem;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #f9fafb;
color: #101828;
font: 600 .875rem/1 system-ui, sans-serif;
cursor: pointer;
}
.panel__action:focus-visible { outline: 2px solid #7aa2ff; outline-offset: 2px; }
/* The theme block. Note the selectors: identical to the base rules above, not
prefixed with any theme selector. Same specificity, later source order. */
@container style(--theme: dusk) {
.panel { background: #1c2333; border-color: #33405c; color: #e8ecf7; }
.panel__body { color: #a9b4cc; }
.panel__action { background: #26304a; border-color: #3c4a69; color: #e8ecf7; }
.panel__action:focus-visible { outline-color: #9dbaff; }
}
</style>
</head>
<body>
<div class="region region--dusk">
<section class="panel">
<h2 class="panel__title">Deployment window</h2>
<p class="panel__body">Changes are applied during the next maintenance window.</p>
<button class="panel__action" type="button">Review changes</button>
</section>
</div>
<div class="region region--day">
<section class="panel">
<h2 class="panel__title">Audit log</h2>
<p class="panel__body">Retained for ninety days, then archived.</p>
<button class="panel__action" type="button">Open log</button>
</section>
</div>
</body>
</html>
Both panels use byte-identical markup. The only difference is which token value they inherit, and both regions sit on the same page — which no single global theme class can express without a second class vocabulary for exceptions.
Key technique: same selector, later source order
This is the property that makes token theming maintainable, and it is worth stating precisely. The @container at-rule contributes nothing to specificity. A rule written as .panel__action { … } inside a style query has specificity 0,1,0 — exactly what it would have outside. When it competes with the base .panel__action rule, the winner is whichever appears later in the stylesheet.
That has two practical implications.
Theme blocks must come after base rules. There is no specificity cushion protecting them. Put the base component styles first and every theme block after, or place them in explicit cascade layers so order is declared rather than inferred — the tradeoff between layers and specificity tricks is set out in cascade layers vs specificity hacks.
An unrelated compound selector still beats a theme block. If some legacy rule says .sidebar .panel__action { background: #fff } at 0,2,0, your theme block at 0,1,0 loses no matter where it sits. This is the failure mode teams hit when introducing token theming into an existing sheet, and the fix is to reduce the offending selector, not to escalate the theme block. Escalating puts you straight back into the specificity race the token was meant to end.
The absence of a specificity bump is also what makes nesting free. Redeclare --theme on an inner region and its subtree re-themes, because the query resolves against the nearest ancestor holding the token. No :not() exclusions, no theme-reset class, no ordering puzzle.
Variation: a second axis without multiplying rules
Add emphasis as an independent token and the two axes compose instead of multiplying. With classes you would need .panel--dusk.panel--loud; here each block is written once and both apply when both tokens match.
/* Axis 2, declared alongside the theme on any ancestor. */
.region--loud { --emphasis: loud; }
@container style(--emphasis: loud) {
.panel { border-width: 2px; }
.panel__title { font-size: 1.25rem; }
}
/* Only where the two genuinely interact do you write a combined query — and this
is the exception, not the rule, so there is one of these rather than N × M. */
@container style(--theme: dusk) and style(--emphasis: loud) {
.panel { box-shadow: 0 0 0 1px #7aa2ff inset; }
}
For right-to-left interfaces nothing changes, because the theme axis is colour and the component already uses logical properties for spacing. That independence is the point: a token axis should describe one concern, and axes that describe different concerns should never appear together in a selector.
Browser support
Style query theming works in Chrome and Edge 111+, Firefox 151+, and Safari 18+, so every current engine themes from the token. Older versions of any of those engines drop the theme block, and the design survives that cleanly because the block only ever overrides colours the base rules already set — which is why the base rules above are a complete, legible light theme rather than a partial starting point. Where a dark result is genuinely required on the oldest engines you support, keep a prefers-color-scheme block as the baseline and use the token only for per-region overrides that no media query can express; the layering pattern for that is in style query fallbacks and support.
FAQ
Why not just use a theme class on the body element?
A body class forces every themed rule to be written as a descendant selector, which raises specificity and couples the component to a global class name. A token inherits without appearing in any selector.
Does a style query add specificity to the rules inside it? No. At-rules contribute nothing to specificity, so a rule inside a style query has exactly the specificity of its own selector. Two competing theme blocks are resolved by source order unless their selectors differ.
Can I nest one theme inside another? Yes. Because the token inherits and the query resolves against the nearest ancestor, redeclaring the token on an inner region themes that subtree only, with no selector changes anywhere.
How does this interact with prefers-color-scheme?
Set the token from the media query rather than restyling components inside it. One rule maps the user preference onto the token, and every existing style query continues to work unchanged.
Related
- Style queries and container state — the parent guide for the
style()model. - Style queries with custom properties — the matching and inheritance rules this page relies on.
- Style query fallbacks and support — keeping the theme correct on browser versions that predate the feature.
- Building responsive cards with container queries — the size-driven counterpart to this token-driven card work.
- Animating custom properties with @property — making token changes transition rather than snap.
Related articles
More pages in the same section.
- Style Queries With Custom Properties: Declaring, Inheriting, and Matching Tokens
- Style Query Fallbacks and Support: Designing for Older Browser Versions
- CSS Anchor Positioning and Overlays: Tethering Elements Without JavaScript
- Anchor-Positioned Tooltips: Tethering, Flipping, and Getting the Semantics Right