Style parent elements from their contents with CSS :has()
Learn how :has() finally lets selectors look at descendants and siblings, where microfrontend shells can use it, and why Shadow DOM plus class-name contracts still bound the pattern.
This article is published in English.
The Direction CSS Selectors Never Went
For decades a CSS selector could describe ancestors and descendants, and it could look sideways to an adjacent sibling, but it could not style an element because of what lived inside it or because of what followed it later. Writing .card img reaches downward into a card to find an image. Writing h2 + p reaches one step forward from a heading to the paragraph that follows. The reverse forms were simply unavailable: nothing let you paint a card based on nested media, and nothing let you paint a heading based on a following paragraph existing later in the tree.
That asymmetry was not an accident of API taste. Engines historically applied styles while walking the document in a mostly forward, top-to-bottom pass. A rule that had to inspect future siblings or deep descendants before deciding whether its subject matched would have broken that single-pass mental model. For roughly twenty-five years every mainstream selector engine respected the constraint, and every team that needed container styling based on contents worked around it instead of through it: a class toggled from JavaScript, an extra wrapper node, or a boolean prop threaded through several components just to mark “this instance has a footer.”
:has() removes that workaround by letting the rule itself look in the direction CSS previously refused to look. Once that door opens, the interesting question is what a selector engine is actually allowed to inspect.
Relational styling also changes how design systems document variants. Instead of requiring authors to remember a modifier class whenever a card includes media, the stylesheet can derive the variant from structure that already exists in the markup. That is convenient, and it is also why accidental structure changes become visual regressions.
What :has() Actually Checks
A pseudo-class is a colon-prefixed condition attached to a selector. Familiar examples include :hover and :first-child: they match because of state or structural position rather than because of a tag name, class, or id. :has() belongs to the same family, but its argument is another selector. It asks whether the subject element contains—or is otherwise related to—something that matches that argument.
.card:has(img) {
border: 2px solid royalblue;
}
The rule can be read almost literally: apply this border to .card only when a matching img exists in the relationship :has() is evaluating. The selector before :has() names the element that receives the declarations. The selector inside the parentheses names only the condition. When the condition is false, :has() matches nothing and the rule never applies.
One fact clears most first-use confusion: declarations attach to the subject before :has(), while the parenthetical selector is only a predicate. The common misreading is “select the image, then style its card.” The mechanism runs the other way. The browser evaluates the condition first, then decides whether to paint the outer subject. That reading order matters when teaching the feature, because demos that highlight the inner matcher make people aim styles at the wrong node.
The phrase “somewhere inside it” still hides important detail. How far does :has() look, and which relationships count?
Descendant, Child, And Sibling, Three Different Reaches
The argument passed to :has() is not locked to “any descendant.” It is a full selector. Combinators—the symbols that define relationships between selector parts—change what the pseudo-class is checking.
/* descendant: image anywhere inside, at any depth */
.card:has(img) { }
/* direct child only: image must be an immediate child of .card */
.card:has(> img) { }
/* next sibling: heading immediately followed by a paragraph */
h2:has(+ p) { }
/* any later sibling: some paragraph follows, not necessarily right after */
h2:has(~ p) { }
Each of those shapes inspects a different slice of the DOM tree. Choosing the wrong one fails silently: nothing throws. A .card:has(img) written when the author meant .card:has(> img) can look correct in a shallow fixture and later match a card whose image sits three wrappers deep after a redesign nests one more container.
At base, the vocabulary is small: a descendant check at any depth, a direct-child restriction, and sibling checks for the immediate next sibling or any following sibling. Hover cascades, form validation styling, empty-state detection, and similar patterns are combinations of those four reaches. Nothing more exotic sits underneath them.
The next question is where that reach pays rent in a production codebase rather than a two-line demo.
A Shell That Reacts To What It Cannot Render Itself
Picture a shell application that owns page layout and mounts other applications into it: a navigation bar, a product listing widget, and a notification panel. Those pieces are built and deployed independently. That runtime composition is usually called a microfrontend architecture. The selling point is that the notifications team can ship without coordinating a release with the shell team.
Independence has a cost the shell must absorb. The shell cannot know ahead of time what the notification panel will render, because the two ships on separate schedules. If the shell wants to shift main content whenever the panel actually has something to show, the historical options were a shared event fired when content mounts or a shared state store both sides read. Either choice couples repositories that were split specifically to reduce coupling.
/* shell/layout.css */
.main-content:has(~ .notification-panel:not(:empty)) {
margin-right: 320px;
transition: margin-right 0.2s ease;
}
<!-- shell/index.html -->
<div class="app-shell">
<main class="main-content">...</main>
<div class="notification-panel" id="notification-slot"></div>
</div>
// notification-panel/mount.js
import { renderAlert } from './alert.js';
export function mount(targetId, alertData) {
const slot = document.getElementById(targetId);
slot.appendChild(renderAlert(alertData));
}
Using :has() here means the margin shifts only once the notification panel contains real content, not the moment an empty host node appears. Notice what those stylesheets omit: no event bus, no shared store, no handshake either team had to implement for the other. Two teams that have never read each other’s code can still coordinate a layout change because both touch the same live DOM.
That absence of explicit coordination is exactly what makes the next limits worth naming.
Where The Reach Stops, And What It Assumes Without Saying So
Two constraints matter more once layout logic spans independently shipped fragments.
Technical isolation comes first. Fragments wrapped in Shadow DOM keep an opaque tree. Outer styles cannot query into that tree, so a shell selector that depends on inner emptiness never fires. Shared light DOM is a prerequisite; default shadow encapsulation in Web Components quietly disables the pattern.
The second constraint is operational. Layout rules that key off .notification-panel assume that class name and containment model remain stable. Rename the class, move content into a portal, or render elsewhere, and the shell stops reacting with no type error and no failing unit test. Debugging then means rediscovering an implicit CSS contract owned by another repository.
Compared with an explicit event or shared store, this is not automatically worse. Coupling still exists; it simply lives in selector text instead of an imported symbol.
Putting The Pieces Back Together
Historical selector engines optimized for a forward pass, which is why container queries of this relational kind arrived late. :has() finally lets a subject react to descendants, children, and following siblings. Those three relationship families are enough to explain the shell layout shift—no additional pseudo-language is required.
The same power explains the failure modes. Crossing into shadow trees fails closed. Depending on foreign class names fails silently. Useful teams treat relational selectors as presentation hooks tied to a documented DOM agreement, not as a substitute for intentional service contracts between microfrontend owners.