Skip to content

Rules & Limits

The main rules you need to know about observed markers and runtime limits are collected on this page.


? and ?! are written at the very end of the selector part, without a space. If a pseudo is present, the marker comes after the pseudo. In a descendant chain, the marker is written on the last element where CSS is applied, not on intermediate selectors. If a selector is observed with ? or ?!, that selector is accepted as the observed root within that source.

/* correct */
.card?
.card?!
.btn:hover?
.btn:focus?!
.accordion-item.is-open .accordion-body?
/* wrong */
.btn?:hover
.btn?!:focus
.card ?
.card ?!
.accordion-item.is-open? .accordion-body

Observed Root Is Inherited by Nested Selectors

Section titled “Observed Root Is Inherited by Nested Selectors”

If the outer selector is observed, it is not necessary to write ? or ?! again on nested selectors.

.card? {
.title { color: red; }
&:hover { color: blue; }
}
@media (min-width: 920px) {
.card { display: flex; }
}

In this source .card .title, .card:hover, and .card inside @media become dynamic observed. If .card?! were used, the same inheritance would work as initial observed.

Root matching is done from the beginning of the selector:

  • .card .title, .card:hover, .card > .title become observed
  • .layout .card, .x + .card, :has(.card) do not become observed

The observed marker has no meaning inside @keyframes and @-webkit-keyframes.

/* wrong usage */
@keyframes fade {
to? { opacity: 1; }
}

Do not use observed markers on :root, html, body, or global reset * selectors. These selectors already exist in normal documents, so observed checks do not provide useful savings.

/* wrong / unnecessary */
:root? { --brand: #2060ff; }
html? { min-height: 100%; }
body? { margin: 0; }
*? { box-sizing: border-box; }
/* correct */
:root { --brand: #2060ff; }
html { min-height: 100%; }
body { margin: 0; }
* { box-sizing: border-box; }

*? is syntactically valid, but it is very broad. Use it only when you truly need "any element inside this subtree exists."

Observed tracks selector presence. The runtime can use class and id mutations for reevaluation because those changes can alter whether a selector matches in the DOM.

.card.is-active? {
outline: 2px solid var(--c-accent);
}

This rule can become active when .is-active appears and deactivate when the class is removed. But if .card is always in the DOM and .is-active is only UI state, static CSS is usually clearer:

.card.is-active {
outline: 2px solid var(--c-accent);
}

Do not use observed as a general-purpose state system; reserve it for cases where the element may genuinely be absent from the DOM.

Observed selectors can be used inside @media and @supports. The at-rule condition is checked first, then the DOM selector check runs.

@media (min-width: 920px) {
.card? { display: flex; }
}

Comma-Separated Selector Parts Are Evaluated Separately

Section titled “Comma-Separated Selector Parts Are Evaluated Separately”

Observed and normal selectors can be mixed with commas in the same rule. The compiler classifies each selector part separately.

.card?, .panel { color: red; }
.d .card, .card, .card .title { padding: 8px; }

Result:

  • .card becomes observed
  • .panel stays static
  • .d .card stays static
  • .card and .card .title become observed

Descendant Chain Is Checked for the Entire Selector

Section titled “Descendant Chain Is Checked for the Entire Selector”
.a .b .c? {
color: red;
}

In this case not just .c but the entire .a .b .c chain must match in the DOM.

Observed looks at whether a selector is or is not in the DOM. It does not track user interaction states like :hover, :focus, :active in an event-based way.

Clear rule:

  • observed = DOM presence
  • static selector = interaction state
/* don't rely on these */
.tooltip-wrap:hover .tooltip?
.btn:focus?
.btn:active?!
/* correct approach */
.tooltip? { opacity: 0; }
.tooltip-wrap:hover .tooltip { opacity: 1; }

Observed makes sense for elements like tooltips, modals, toasts that are sometimes in the DOM and sometimes not. But hover/focus/active visibility or style changes should be written as native CSS selectors.

.card? and .card:hover? Are Not the Same Thing

Section titled “.card? and .card:hover? Are Not the Same Thing”
.card? {
box-shadow: 0 1px 4px var(--c-shadow);
}
.card:hover? {
box-shadow: 0 4px 16px var(--c-shadow-md);
}

This writing is valid. But the meaning of the second line is:

  • .card? → if .card is in the DOM, the base rule becomes active
  • .card:hover? → if .card is in the DOM, the hover rule is also loaded

This does not mean "observe when hovered." Hover changes are not watched separately by observed; hover state still works with native CSS.

So the clearer writing in most cases is:

.card? {
box-shadow: 0 1px 4px var(--c-shadow);
}
.card:hover {
box-shadow: 0 4px 16px var(--c-shadow-md);
}

Short recommendation:

  • .card? or .card?! → makes sense when the element is sometimes not in the DOM
  • .card:hover? or .card:hover?! → unnecessary and confusing in most cases
  • write hover/focus/active state styles as static selectors

::before? and ::after? Work Through the Host Element

Section titled “::before? and ::after? Work Through the Host Element”
.tooltip? {
opacity: 0;
}
.tooltip::after? {
content: '';
border-top-color: var(--c-text);
}

This writing is valid. But the meaning is:

  • .tooltip? → if .tooltip is in the DOM, the base rule becomes active
  • .tooltip::after? → if .tooltip is in the DOM, the ::after rule is also loaded

This does not mean there is a separate observed existence check for the pseudo-element. Since ::before and ::after are not real DOM nodes, observed bases on the host element.

So the clearer writing in most cases is:

.tooltip? {
opacity: 0;
}
.tooltip::after {
content: '';
border-top-color: var(--c-text);
}

Short recommendation:

  • .tooltip? or .tooltip?! if the host element is sometimes not in the DOM
  • leave the pseudo-element rule static in most cases
.player *?

This writing is valid. But since * is a very wide target, it can be unnecessarily expensive on the observed side. If possible, prefer a narrower selector over *?:

  • .player .title?
  • .player .controls?
  • .player button?

Observed can be used with these kinds of selector pseudo-functions. But ? or ?! is always written on the outer tail, not on the sub-selector inside the parentheses.

/* correct */
.shell:not(.is-collapsed)?
.menu:where(.is-ready, .is-open)?!
.panel:is(.wide, .stacked)?
/* wrong */
.shell:not(.is-collapsed?)
.menu:where(.is-ready?!, .is-open)
.panel:is(.wide?, .stacked)
.lead + .item?
.lead ~ .note?!

These selectors are valid. Matching is always verified over the full selector. But re-evaluation depends on the token-index optimization. So for sibling relationships that change frequently, some structural changes like another node being inserted in between may not always be re-checked immediately.

Short recommendation:

  • if the sibling relationship is very dynamic, prefer a static selector
  • observed makes more sense for "sometimes present, sometimes not" scenarios

LimitDefaultShort meaning
maxObservedRules5,000Total observed rule count in the source
maxObservedActiveRules1,000Number of observed rules that can be active at the same time
maxObservedRuleKeysPerToken300Maximum rule key count that can be tracked for a DOM token
maxMutationBatchTokens1,500Number of tokens that can be processed in a single mutation batch
maxObservedReevalPerTick500Number of observed rules that can be re-evaluated in a single tick

What Generally Happens When a Limit Is Exceeded?

Section titled “What Generally Happens When a Limit Is Exceeded?”
  • if the default policy is fail-closed, compilation stops with an error
  • if fail-soft, compilation continues and a resource-limit warning may be produced
  • the behavior on exceeding each limit is not the same; some do filtering, some do trimming, some apply a silent boundary on the runtime side