Style Queries With Custom Properties: Declaring, Inheriting, and Matching Tokens

You write @container style(--variant: compact), set --variant: compact on the element you expect to change, and nothing happens. There is no console warning and no invalid-property strikethrough in DevTools — the block is simply never applied. This page works through the exact mechanics of matching a custom property with a style query: where the declaration has to live, what the engine compares, and the four inheritance behaviours that make an otherwise correct query evaluate false. It sits inside the style queries and container state guide, part of Mastering Container Queries & Responsive Layouts.


Why CSS solves this and JavaScript should not

The instinct when a component needs to react to a piece of state is to compute that state in script and set a class. That works, but it puts a runtime dependency between state and appearance: the class must be applied before first paint or the component flashes its default, and every consumer of the component must know the class vocabulary.

A custom property carries the same information declaratively. It inherits, so it can be set once at a region boundary and read at any depth without being threaded through props or wrappers. It is available at style-computation time, so there is no flash. And it degrades to nothing — an engine that cannot evaluate style() simply keeps the default branch, which is the behaviour you want anyway.

The tradeoff is that a custom property is invisible to assistive technology. A token change is a purely visual event; where the state is semantically meaningful the markup must carry it too, via an ARIA attribute or real text. Use tokens to decide how something looks, never as the sole record of what it is.


The ancestor rule, in one diagram

Where the token must be declared A style query matches when the custom property is declared on an ancestor and fails when it is declared on the queried element. Where the token must live matches never matches parent element --state: on queried child style(--state: on) parent declares nothing --state: on style(--state: on) the query reads the parent, never the element's own declarations

Both notes in the demo below declare the same token with the same value. Only the one whose parent declares it is restyled.

Live demoA style query reads the ancestor, never the element itself
Both notes declare --state: highlight. Only the one whose PARENT declares it is restyled — an element cannot match its own value.

Complete working implementation

The following is self-contained. It shows the correct placement, the incorrect placement side by side, and a component root that supplies its own default — the pattern you will actually ship.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Style query token placement</title>
<style>
  body { font: 400 16px/1.5 system-ui, sans-serif; margin: 2rem; color: #101828; }

  .stack { display: grid; gap: .9rem; max-width: 34rem; }

  /* CORRECT: the token sits on an ancestor of the element the query restyles. */
  .holder { --state: highlight; }

  /* INCORRECT: the token sits on the element being queried. The element is not
     its own style query container, so style(--state: highlight) is false here. */
  .note--self { --state: highlight; }

  .note {
    margin: 0;
    padding: .8rem 1rem;
    border: 1px dashed #d0d5dd;
    border-radius: 8px;
    background: #fff;
    color: #667085;
  }

  @container style(--state: highlight) {
    .note {
      border-style: solid;
      border-color: #7aa2ff;
      color: #101828;
      box-shadow: inset 3px 0 0 #7aa2ff;
    }
  }

  /* The shipping pattern: a component root declares its own default token, and the
     query restyles the root's DESCENDANTS. The ancestor requirement is satisfied
     because .widget is an ancestor of .widget__label. */
  .widget { --mode: idle; padding: 1rem; border: 1px solid #d0d5dd; border-radius: 10px; }
  .widget[data-mode="busy"] { --mode: busy; }
  .widget__label { margin: 0; font-weight: 600; }

  @container style(--mode: busy) {
    .widget__label::after { content: " · working"; color: #667085; font-weight: 400; }
  }
</style>
</head>
<body>
  <div class="stack">
    <div class="holder">
      <p class="note">Parent declares the token — this note is highlighted.</p>
    </div>

    <p class="note note--self">This note declares the token on itself — unchanged.</p>

    <div class="widget" data-mode="busy">
      <p class="widget__label">Export</p>
    </div>
  </div>
</body>
</html>

The .widget block is the important one. It looks like self-querying and is not: the declaration is on .widget, the query restyles .widget__label, and .widget is that label's ancestor. Almost every real style query has this shape, which is why the rule feels invisible until the day you try to restyle the token-bearing element itself.


Key technique: match on computed value, not declared value

The engine does not compare source text. It computes the custom property on the ancestor first, then compares the resulting token sequence with the one in the query. Three consequences follow.

Substitution resolves first. If the ancestor has --state: var(--incoming, highlight), the query sees whatever var() produced. You can therefore drive a query from a value passed down through several indirections, which is how design-token layers stay composable.

Unregistered properties compare tokens literally. A custom property with no @property registration has no type; its computed value is essentially its trimmed token stream. --ratio: 1.0 does not match style(--ratio: 1), and --tone: Dusk does not match style(--tone: dusk). Registering the property fixes the numeric case because registration forces computation to the declared syntax:

@property --ratio {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}
/* Now --ratio: 1.0 computes to 1 and style(--ratio: 1) matches. */

Leading and trailing whitespace is normalised, internal whitespace is not. --gap: 8px matches style(--gap: 8px), but --font: system-ui, sans-serif will not match a query written with a single space. Keep queried tokens to single keywords wherever you can; they are identifiers, not values you should be pretty-printing.

The boolean form

style(--token) with no value tests whether the property holds anything other than its initial guaranteed-invalid value. It is the right tool for optional tokens:

/* Applies whenever an ancestor has set --accent to anything at all. */
@container style(--accent) {
  .chip { border-color: var(--accent); }
}

Note that a property explicitly set to initial fails this test, while one set to an empty value technically passes — an easy way to produce a query that is true with no useful value behind it. Prefer explicit keyword values over presence-testing when the distinction matters.


Inheritance gotchas

Four behaviours account for nearly every query that fails despite correct placement.

Redeclaration on an intermediate element wins. Inheritance stops at the nearest declaration. If a wrapper between your region and your component sets --theme: light — even as a reset — descendants read light, not the region's value. Search for the property name across the whole stylesheet before assuming the region's value reaches the leaf.

all: revert and aggressive resets can strip tokens. A reset that applies all: unset to a wrapper removes inherited custom properties on that element, silently cutting the chain. Scope such resets narrowly, and keep token declarations in their own layer as described in cascade layers for reset and tokens.

Registered properties can be non-inheriting. @property with inherits: false produces a property that does not flow to descendants. A query on a child then reads the initial value, not the parent's. If you register a token that a style query will read, inherits: true is almost always what you want.

Shadow boundaries pass values but not declarations. Custom properties inherit into shadow trees, so a token set on a host element is readable by a style query inside its shadow root. But a token set inside the shadow root is invisible outside it. Components that expose theming through a host-level token work with style queries; components that hide their tokens do not.


Variation: naming the container to skip intermediate declarations

When several ancestors legitimately declare the same token — a page region and a nested card both setting --density — an unnamed query resolves against whichever is nearest. Naming the container pins resolution to a specific level:

.region {
  container-name: region;
  --density: comfortable;
}

.card { --density: compact; }

/* Reads the CARD's value, because .card is nearest. */
@container style(--density: compact) {
  .card__body { padding: .5rem; }
}

/* Reads the REGION's value, skipping .card entirely. */
@container region style(--density: comfortable) {
  .card__footer { padding-block: .75rem; }
}

A named style query still walks up to the nearest ancestor carrying that container-name, so naming is the disambiguation tool here exactly as it is for size queries — the general rules are in nesting and naming container queries.


Browser support

Custom-property style queries ship in Chrome and Edge 111+, Firefox 151+, and Safari 18+ — every current engine evaluates them. The support question that remains is about older versions still in circulation, where the block is dropped at parse time and the default branch renders instead. Because @supports has no dependable test for the feature, the practical safeguard is structural rather than conditional: never put anything load-bearing inside a style() block, and verify the page is complete with those blocks removed. The full strategy is in style query fallbacks and support.


FAQ

Where exactly must the custom property be declared? On an ancestor of the element you want to restyle. The nearest ancestor's computed value is what the query tests, and the element's own declarations are never consulted.

Does the value have to match exactly? For unregistered properties the comparison is on the computed token sequence, so casing and unit notation matter. Registering the property with @property normalises the value first and makes matching predictable.

What does the boolean form style(--token) test? It is true when the custom property has any value other than its initial guaranteed-invalid value. It is the way to test whether a token is set at all, rather than testing it against a specific value.

Can a component set its own default and still be queried? Yes, if the default is declared on the component root and the queried rules target its descendants. The root supplies the value and the children read it, which satisfies the ancestor requirement.


Related articles

More pages in the same section.