Style Queries and Container State: Token-Driven Component Variants

Size queries let a component read how much room it has. Style queries let it read what it has been told — a value sitting in a custom property somewhere above it in the tree. This guide covers the style() half of the @container at-rule and belongs to the Mastering Container Queries & Responsive Layouts guide, extending the mechanics introduced in Container Query Syntax Basics. The payoff is architectural: a component can carry every one of its variants in one stylesheet, activated by a single inherited token, with no variant class threaded through the markup and no JavaScript deciding which class to apply.

How a style query resolves against an ancestor A token declared on an outer region inherits through an untyped wrapper and is matched by a descendant's style query. Style query resolution ancestor region declares the token --theme: dusk wrapper: inherits, declares nothing queried element style(--theme: dusk) inherits downward an element never matches a token it declares on itself

What this guide covers:

  • The evaluation model that separates style() from size conditions
  • Why current implementations accept custom properties only
  • Declaring, inheriting, and matching state tokens
  • Replacing variant class permutations with a single token axis

Prerequisites

Before working through the code here, you should be comfortable with:

  • The @container at-rule and how a query names or omits a container.
  • Custom property declaration and inheritance — that --x set on an element flows to every descendant until redeclared.
  • The difference between a declared value and a computed value, since style queries match computed values.
  • Basic cascade reasoning: specificity, source order, and the fact that at-rules do not contribute specificity.
  • Optionally, container-type semantics as covered in container-type: size vs inline-size — useful context, though style queries do not require it.

Core concept: querying state instead of space

A size query asks a geometric question — is the container at least 400 pixels wide? The engine must lay the container out before it can answer, which is why size containment is required and why container-type exists at all.

A style query asks a completely different question — does the container's computed value for this property equal this value? No layout is involved. The answer depends only on style computation, which happens earlier in the rendering pipeline and does not require the container to have been sized, contained, or even rendered.

That difference has one immediate and frequently surprising consequence: every element is a style container by default. The initial value of container-type is normal, and normal still establishes a style query container. You do not opt in. A bare @container style(--tone: warning) { … } inside a rule set resolves against the parent of each matched element, because the parent is the nearest ancestor and every ancestor qualifies.

The spec language is worth internalising: a style query is evaluated against the nearest ancestor style query container, and a query container never includes the element being styled. The element's own declarations are invisible to its own query. This is not a quirk to work around; it is what makes the mechanism safe, since self-matching would create trivially circular style dependencies where a rule's outcome changes the value the rule tested. The mechanics of that ancestor rule, and the mistakes it produces, are worked through in detail in style queries with custom properties.

The custom-property-only limitation

CSS Conditional Rules Level 5 defines style() to accept any declaration, including standard properties: @container style(font-style: italic) is valid in the specification. No shipping engine implements that. As of mid-2026 Chromium, Gecko, and WebKit all restrict style() to custom properties, and a query naming a standard property simply never matches — it does not throw, warn, or fall back. It silently evaluates false.

Treat that as a design constraint rather than a temporary gap. Even when standard-property queries land, routing component state through explicit tokens is the more legible pattern: --density: compact states intent, whereas padding: 4px states an outcome and invites brittle coupling between a component's internals and the rules that test them.


Syntax and parameters

TokenAccepted valuesDefault / notes
style( … )A single declaration: --name: valueThe only query function that reads computed style
--nameAny custom property nameStandard property names parse but never match in shipping engines
valueAny token sequence; compared after computationMatching is on computed value, whitespace-normalised
style(--name)Boolean form — property has a value other than its initialGuaranteed-invalid values (unset custom properties) fail
not style( … )Negation of a single queryUseful for expressing a default branch
style(a) and style(b)ConjunctionEach style() wraps exactly one declaration
style(a) or style(b)DisjunctionParenthesise when mixing and with or
container name@container card style(--x: y)Restricts resolution to the nearest ancestor with that container-name
container-typeNot required for style()normal (the initial value) already qualifies as a style container

Two syntax details cause most parse-time confusion. First, each style() holds exactly one declaration — style(--a: 1; --b: 2) is invalid; write style(--a: 1) and style(--b: 2). Second, mixing size and style conditions in one query is allowed and often useful: @container card (min-width: 400px) and style(--variant: feature) resolves the size half against the nearest ancestor named card that has a container-type, and the style half against the nearest ancestor named card regardless.


Step-by-step implementation

Step 1 — Declare the token where the decision belongs

Put the custom property on whichever element owns the decision. For a component-level variant that is the component root; for a page-region theme it is the region wrapper. Always give it a default so the query has something deterministic to read.

.card {
  --variant: comfortable; /* every card has a defined value from the start */
  border: 1px solid #d7dce5;
  border-radius: 10px;
  background: #fff;
}

.card--compact { --variant: compact; }
.card--feature { --variant: feature; }

The modifier classes here set nothing but a token. They carry no visual declarations, so they never enter a specificity contest with the rules that respond to them.

Step 2 — Write the default appearance with no query at all

Everything outside a query is what the component looks like when no token matches — in an unsupporting engine, or when the token holds an unrecognised value. Make this branch complete and correct on its own.

.card__body { padding: 1rem; color: #101828; }
.card__title { margin: 0 0 .25rem; font: 600 1rem/1.3 system-ui, sans-serif; }
.card__meta { margin: 0; color: #667085; font: 400 .8rem/1.4 ui-monospace, monospace; }

Step 3 — Add one query block per token value

Each block reads the token from the nearest ancestor. Because .card__body is a child of .card, the token declared in step 1 is exactly one hop away.

@container style(--variant: compact) {
  .card__body { padding: .5rem .75rem; display: flex; align-items: baseline; gap: .6rem; }
  .card__title { margin: 0; font-size: .9rem; }
}

@container style(--variant: feature) {
  .card__body { padding: 1.4rem; border-left: 4px solid #7aa2ff; }
  .card__title { font-size: 1.35rem; }
}

Drag your eye across the three cards below — the markup for all three is identical apart from one class that sets one custom property.

Live demoOne token drives three card variants
Each card sets a different --variant value; the same @container style() rules reshape the body. Requires a browser with style query support (Chrome 111+, Safari 18+).

Step 4 — Combine with a size condition where both matter

Size and style answer different questions, so let each own what it is good at: geometry from the size query, intent from the token.

.panel {
  --tone: neutral;
  container-type: inline-size; /* required for the size half only */
  border: 1px solid #d7dce5;
  border-radius: 10px;
  background: #fff;
  padding: 1rem;
}
.panel--alert { --tone: alert; }

.panel__inner { display: flex; flex-direction: column; align-items: flex-start; gap: .6rem; }

/* Geometry */
@container (min-width: 380px) {
  .panel__inner { flex-direction: row; align-items: center; gap: 1rem; }
}

/* Intent */
@container style(--tone: alert) {
  .panel__badge { background: #7aa2ff; color: #10182b; }
  .panel__text { font-weight: 600; }
}

Resize the frame and note that the two conditions never interfere — narrowing the panel does not change its tone, and changing its tone does not change its axis.

Live demoSize query and style query on one component
Drag the frame narrower: the size query flips the layout axis while the --tone token keeps deciding the accent, independently. Drag the bottom-right corner to resize the frame in either direction.

Annotated production example: a notification list item

This is a realistic component with three orthogonal state axes — severity, density, and read state — that would classically require a matrix of modifier classes. Here each axis is one token.

<ul class="feed" style="--density: comfortable">
  <li class="feed__item" style="--severity: critical; --read: no">
    <div class="feed__marker" aria-hidden="true"></div>
    <div class="feed__content">
      <p class="feed__title">Certificate expires in 48 hours</p>
      <p class="feed__detail">edge-gateway-03 · renewal not scheduled</p>
    </div>
  </li>
  <li class="feed__item" style="--severity: info; --read: yes">
    <div class="feed__marker" aria-hidden="true"></div>
    <div class="feed__content">
      <p class="feed__title">Nightly backup completed</p>
      <p class="feed__detail">14 volumes · 2m 11s</p>
    </div>
  </li>
</ul>
.feed {
  --density: comfortable;      /* list-level axis, inherited by every item */
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: .5rem;
}

.feed__item {
  --severity: info;            /* item-level axes, overriding nothing above */
  --read: no;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: .75rem;
  align-items: start;
  padding: .9rem 1rem;
  border: 1px solid #d7dce5;
  border-radius: 10px;
  background: #fff;
}

.feed__marker {
  inline-size: 10px;
  block-size: 10px;
  margin-block-start: .35rem;
  border-radius: 50%;
  background: #98a2b3;
}

.feed__title { margin: 0; font: 600 .95rem/1.4 system-ui, sans-serif; color: #101828; }
.feed__detail { margin: .15rem 0 0; font: 400 .82rem/1.4 system-ui, sans-serif; color: #667085; }

/* Axis 1 — severity. Read from .feed__item by its own children. */
@container style(--severity: critical) {
  .feed__marker { background: #b42318; }
  .feed__title { color: #912018; }
}

@container style(--severity: warning) {
  .feed__marker { background: #b54708; }
}

/* Axis 2 — read state. Independent of severity; the two blocks compose. */
@container style(--read: yes) {
  .feed__title { font-weight: 400; }
  .feed__marker { background: transparent; box-shadow: inset 0 0 0 2px #d0d5dd; }
}

/* Axis 3 — density. Declared on .feed, so .feed__item reads it from its parent. */
@container style(--density: compact) {
  .feed__item { padding: .5rem .75rem; gap: .5rem; }
  .feed__detail { display: none; }
}

Three points deserve attention. The density block styles .feed__item itself, which works because the token lives one level up on .feed — had it been declared on .feed__item, the item could not read it. The severity and read blocks style children of the element carrying the token, which is the ordinary arrangement. And the blocks compose freely: a critical unread item picks up declarations from two blocks with no combinatorial rule like .feed__item--critical.feed__item--unread anywhere in the sheet.

Adding a fourth severity value costs one block. Adding a fourth axis costs one block per value, not a multiplication against everything already present. That linear-instead-of-multiplicative growth is the whole argument, and it is examined against the class-based and attribute-based alternatives in container style query theming.


Performance and accessibility notes

Style queries do not force layout. Unlike size queries, which need the container's box before they can be answered, a style query is resolved during style computation. There is no containment cost, no contain side effect, and no risk of the layout thrashing that deeply nested size containers can cause. If you have been rationing container queries for performance reasons, that budget does not apply here.

The cost that does exist is invalidation breadth. Changing a token on a high element invalidates style for its whole subtree, and the engine must recompute matches for every descendant with a query that reads it. Declaring --theme on :root and querying it from a thousand nodes is fine when it changes on user action; it is wasteful when the value is animated frame by frame. Keep token mutation event-driven, not continuous.

Custom properties registered with @property compute to their specified syntax before matching, which makes comparisons predictable — --ratio: 1.0 registered as <number> matches style(--ratio: 1) where an unregistered property would compare the literal token sequences instead. The type behaviour is covered from the other direction in registered properties and type safety.

On accessibility, remember what a style query is not: a token change alters presentation only. If --state: error turns a field red, assistive technology learns nothing from it. The token must accompany an accessible signal — aria-invalid, role="alert", real text — not replace one. Colour-only distinctions between severity levels also fail WCAG 1.4.1 (Use of Color); pair every colour shift with a shape, icon, or text label, exactly as the marker-plus-title pairing does above.

Finally, tokens driving motion should still respect user preference. A --motion: expressive token must sit under the same prefers-reduced-motion discipline as any other animation trigger, per reducing motion preferences in CSS.


DevTools debugging workflow

  1. Confirm the engine supports the feature at all. Every current engine does, so this is a question about the version in front of you rather than the brand. Check the Styles pane for whether your @container style(...) block is listed as an at-rule group at all: a version predating the feature omits the block entirely, which is the fastest way to tell a support problem from a code problem.
  2. Select the queried element, not the container. In the Elements panel, the Styles pane shows matched rules grouped under their @container header. If your block is absent, the query did not match for this element.
  3. Walk one level up and read the Computed tab. Filter for the custom property name. The value shown on the parent is the value the query tested. If the property appears on the selected element but not on its parent, you have the self-declaration bug.
  4. Check whitespace and case in the computed value. Custom properties preserve their token stream. A trailing comment or an unexpected Dusk versus dusk will fail an equality test that looks correct in source.
  5. Edit the token live. Change the parent's custom property value directly in the Styles pane; matching blocks appear and disappear on the child immediately. This is the fastest confirmation that resolution is wired correctly, and it also exercises invalidation so you can watch the Performance panel for recalculation cost.
  6. For named queries, verify the name resolves. @container card style(--x: y) needs an ancestor with container-name: card; if no ancestor matches the name, the query is false regardless of token values.

Browser compatibility

FeatureChrome/EdgeFirefoxSafariNotes
@container style(--prop: value)111+151+18+Custom properties only
Boolean form style(--prop)111+151+18+True when the property has a non-initial value
style() with standard propertiesNot implementedNot implementedNot implementedSpecified, but no engine ships it
style() combined with a size condition111+151+18+Size half still needs container-type
Style container without container-type111+151+18+normal already qualifies

Custom-property style queries are now implemented in all three engines: Chromium since 111, Safari since 18, and Firefox since 151. The remaining support question is therefore about older versions still in circulation rather than about a hold-out engine, and it is answered structurally — keep every style() block additive so an engine that drops it still renders a finished design. That discipline, and how to verify it, is set out in style query fallbacks and support; the general detection technique for containment is in feature detection with @supports.


Common pitfalls

PitfallCauseResolution
Query never matches although the value looks rightThe property is declared on the element being styled, not on an ancestorMove the declaration to a parent, or introduce a wrapper that carries the token
style(display: grid) does nothingStandard properties are not implemented in any shipping engineRoute the state through a custom property instead
Two blocks fight and the wrong one wins@container adds no specificity; an unrelated selector outranks bothEqualise selector specificity between blocks, or place them in an ordered layer
Value mismatch on a numeric tokenUnregistered custom properties compare token streams, so 1.0 is not 1Register the property with @property and a <number> syntax
Theme flickers or lags on toggleToken declared very high and mutated on every animation frameDeclare tokens close to their consumers and change them on discrete events

FAQ

Do style queries need container-type on the ancestor? No. Every element is a style container by default, so container-type is only required for size queries. Declaring container-type: inline-size does not disable style queries, and omitting it does not prevent them.

Can I query a standard property like color or display? Not in any shipping engine as of mid-2026. The specification allows querying non-custom properties, but every engine that ships style() restricts it to custom properties, so style(color: red) never matches.

Why does my style query fail when the element sets the property itself? A style query is evaluated against the nearest ancestor container, and an element is never its own container. Move the declaration to a parent element and the query will match.

How do style queries interact with specificity? The @container at-rule adds no specificity at all. Rules inside it carry exactly the specificity of their own selectors, so a style query cannot outrank a more specific selector declared elsewhere.


Specification references

  • CSS Conditional Rules Module Level 5 — defines style(), the boolean form, and the style query container concept. W3C Spec
  • CSS Containment Module Level 3 — defines container-type: normal and the query container hierarchy. W3C Spec
  • CSS Properties and Values API Level 1@property registration and computed-value behaviour for custom properties. W3C Spec

Related articles

More pages in the same section.