:has() vs Container Style Queries: Discovered State and Published State
Both features let a component react to something other than its own classes, and both shipped within a couple of years of each other, so teams often reach for whichever one they learned first. They are not interchangeable. :has() discovers state by inspecting the DOM beneath an element. A container style query reads published state — a custom property's computed value on an ancestor. This page compares them on the axes that decide which one to use: where the state lives, how far it travels, how it composes, and what it costs. It sits in the Mastering Container Queries & Responsive Layouts guide.
Two directions of information flow
The simplest way to tell them apart is to follow the information. With :has(), information flows upward: a descendant's existence or state reaches an ancestor, which styles itself. With a style query, information flows downward: an ancestor's custom property reaches its descendants through inheritance, and they style themselves.
Most of the practical differences follow from that. :has() can react to things that are not styles at all — the presence of an element, a form control's checked or invalid state, focus, hover. A style query can only react to a custom property value, but that value can come from anywhere above: a class on a distant section wrapper, an inline style set by a CMS, a @layer of theme tokens, or even another rule that converted DOM state into a property.
The complete implementation: one alert panel, both ways
The panel below shows an alert tone in two ways. Version A discovers the tone by checking which radio button inside it is selected. Version B reads a --tone property published by whatever section it sits inside.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>:has() vs style queries</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; display: grid; gap: 1.5rem; }
.panel {
padding: 1rem;
border: 2px solid #94a3b8;
border-radius: 10px;
}
.panel h2 { margin: 0 0 0.5rem; font-size: 1.1rem; }
/* ---- Version A: discovered with :has() ----------------------------- */
/* The state is a form control inside the panel. The panel inspects
its own subtree and restyles itself. */
.panel--a:has(input[value="alert"]:checked) {
border-color: #b91c1c;
background: #fef2f2;
}
.panel--a:has(input[value="calm"]:checked) {
border-color: #15803d;
background: #f0fdf4;
}
/* ---- Version B: published with a property, read by style() ---------- */
/* Any ancestor can publish --tone. Here it is a section wrapper that
could be many levels above the panel. */
.section--alert { --tone: alert; }
.section--calm { --tone: calm; }
/* The section is the query container. Style queries need a container
but not a size container-type, so container-name alone is enough. */
.section { container-name: section; }
@container section style(--tone: alert) {
.panel--b { border-color: #b91c1c; background: #fef2f2; }
}
@container section style(--tone: calm) {
.panel--b { border-color: #15803d; background: #f0fdf4; }
}
</style>
</head>
<body>
<div class="panel panel--a">
<h2>A: tone picked inside</h2>
<label><input type="radio" name="tone" value="calm" checked> Calm</label>
<label><input type="radio" name="tone" value="alert"> Alert</label>
</div>
<section class="section section--alert">
<div class="panel panel--b">
<h2>B: tone set by the section</h2>
<p>This panel does not know why it is red; it only reads --tone.</p>
</div>
</section>
</body>
</html>
Version A is self-contained: the panel owns the control, so discovery is natural. Version B is decoupled: the panel has no idea which class or element set --tone, and the same panel dropped into a .section--calm, or into a CMS block with style="--tone: calm", adapts with no change to its CSS.
The key technique: choosing by where the state lives
The middle branch is worth dwelling on, because it is where teams over-engineer. If a component sets a value on itself and styles itself from it, neither feature is needed — a class or attribute selector is simpler and faster. Style queries exist for values that arrive from outside the component through inheritance, where the component cannot know which selector set them.
The two can also be chained, and this is a powerful pattern. A component uses :has() to discover DOM state and publishes it as a property; descendants anywhere below read the property with a style query. The component's internal structure can change freely, because descendants depend only on the published name.
/* Discover once, near the source... */
.product:has(> .product__options input[value="gift"]:checked) {
--product-mode: gift;
}
/* ...and let any descendant read the published name. */
.product { container-name: product; }
@container product style(--product-mode: gift) {
.product__price { text-decoration: line-through; }
.product__gift-note { display: block; }
}
How they compare in practice
Two rows of that grid cause most bugs when switching between them. First, the "query self" row: a style query always consults a container ancestor, so writing --tone: alert on .panel and then @container style(--tone: alert) { .panel { … } } does nothing — the panel is not its own container. Move the property up one level. Second, the reach row: :has() can see siblings through :has(+ x) and :has(~ x), which style queries cannot do at all.
Cost is the row deliberately left out, because it depends on the page. :has() is paid when something in its argument changes, scaled by how broad the anchor is — the analysis in @container block for the container's descendants. A property toggled on hover near the root is as costly as a hover-driven :has() near the root.
Debugging each mechanism
The two features fail in characteristically different ways, and knowing the failure signature saves time.
A :has() rule that does not apply almost always has a selector problem, and the Styles pane in any browser's DevTools shows it plainly: select the subject element and the rule is either listed (it matched) or absent (it did not). The usual culprits are a missing child combinator that makes the argument match somewhere unexpected, a pseudo-class that is not in the state you think — :checked on a radio in a different group, :user-invalid before the user has interacted — and specificity, since :has() takes the weight of its most specific argument and can quietly outrank or lose to neighbouring rules. Toggle the element's state with the :hov panel to force :hover, :focus or :focus-visible and watch the rule appear.
A style query that does not apply almost always has a container problem. Chromium's Styles pane shows @container rules with the container they resolved against; if the query is attached to the wrong ancestor, or to none, that label says so. Check three things in order. Does an ancestor have a container-name or container-type that establishes it as a query container? Is the custom property set on that container or above it, rather than on the element being styled? And does the value match exactly? Style queries compare computed values, and an unregistered custom property's computed value is its token sequence, so --tone: alert and --tone:alert match, but --tone: "alert" with quotes does not match style(--tone: alert).
When a design system chains the two — discover with :has(), publish as a property, read with style() — debug from the source outward. Confirm the :has() rule applies on the publishing element, then use the Computed pane on a descendant to confirm the property has inherited with the expected value, and only then look at the @container block. Working in that order finds the broken link in the chain in three clicks instead of ten.
Variation: themes as published state
Theming is the canonical style-query use case, and it shows why published state scales better than discovery for values that come from above. A page section sets --surface: dark; every component inside adapts, whether it is nested one level or ten. The same effect with :has() would require each component to find the section that set the theme, which is not what :has() does — it looks down, not up.
.theme-dark { --surface: dark; }
.theme-light { --surface: light; }
/* Any element can be the container for style queries. */
.theme-dark, .theme-light { container-name: theme; }
@container theme style(--surface: dark) {
.button { background: #e2e8f0; color: #0f172a; }
.card { background: #1e293b; color: #f1f5f9; }
}
The full treatment, including nested themes and fallbacks, is in Container Style Query Theming. If the theme should animate as it changes, registered custom properties make the transition smooth; Animating Custom Properties With @property explains why an unregistered property cannot interpolate.
Browser support
:has() ships in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+. Style queries for custom properties ship in Chrome and Edge 111+, Firefox 151+ and Safari 18+, so they are the newer and less universally deployed of the two. For a style-query-based variant, keep a sensible default outside the @container block and treat the query as an enhancement; Style Query Fallbacks and Support covers detection with @supports and graceful defaults.
FAQ
Can a style query replace :has()?
Only when the state can be expressed as a custom property on an ancestor. Style queries read computed values, so they cannot see whether a child element exists, whether an input is checked, or whether something is hovered, unless some rule has already turned that into a property.
Can :has() replace a style query?
For state that lives in the DOM, yes. For state that is inherited through custom properties from far above, such as a theme token set on a section wrapper, :has() would need to find the element that set it, which is fragile. Style queries read the inherited value directly.
Why does my style query not match a property set on the element itself? A style query evaluates the custom property on the query container, which is an ancestor, not on the element being styled. Set the property on the container or anything above it and query it from descendants. An element cannot query its own style.
Which one is faster?
Neither is categorically faster. :has() pays when its argument changes and cost grows with the breadth of the anchor. A style query pays when the queried property changes and every descendant rule that depends on it must be re-evaluated. Measure the specific case when it matters.
Related
- Parent-Aware Layouts With
() — the parent guide to relational selectors. - Style Queries With Custom Properties — the syntax of style() in depth.
- Content-Aware Card Layouts With
() — discovery used for component variants. - Registered Properties and Type Safety — making published state typed and animatable.
Related articles
More pages in the same section.