C.W.K.
Stream
Lesson 05 of 05 · published

:has() In Practice: Parent-State Styling Without JavaScript

~12 min · has, parent-selector, modern-css, less-js

Level 0Markup Novice
0 XP0/34 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
":has() didn't add a feature. It removed an entire category of JavaScript."

The Pattern Shift

For two decades, the answer to "style a parent based on what's inside it" was: write JavaScript that watches the children, toggles a class on the parent, and styles the class. Every form with a "submit disabled until valid" indicator did this. Every layout with "hide aside when main is empty" did this. Every card that lit up when an interior link was hovered did this.

:has() made all of that obsolete.

The Five High-Value Patterns

1. Form-Wide Validation Visualization

Indicate that a form has problems without writing per-field error handling. The form itself takes the highlighted state:

2. Sidebar / Pane Adaptation

Hide a sidebar when there's no main content, or show one when content meets a criterion:

3. Interactive Card Highlighting

Highlight a card when a child link or button is hovered or focused, without JavaScript:

4. Conditional Decoration Based On Descendant

Add a marker to elements based on what they contain:

5. Layout Variation Based On Slotted Content

A grid layout that adapts based on whether a featured item is present:

Sibling :has Patterns

:has() can use any combinator inside, not just descendants. Sibling-aware patterns:

  • li:has(+ li:hover) — the item just before the hovered one (style preceding item).
  • li:has(~ li.active) — items before the active one.
  • label:has(+ input:invalid) — labels whose adjacent input is invalid.

The Performance Concern (And Why It's Mostly Moot)

Early skepticism about :has() performance was real: a naive implementation would re-evaluate every parent's :has match on every DOM change. Modern browsers (2023+) optimize this with selector caching and invalidation tracking. For typical applications, :has() performance is indistinguishable from other selectors. Only deeply-nested :has() with complex inner selectors on very large DOMs causes measurable overhead — and there, the right answer is to simplify the inner selector, not avoid :has().

What :has() Doesn't Do

  • Doesn't watch dynamic content changes that aren't in the DOM. If a child's intrinsic state is JavaScript-only (a React state, a Web Component internal), :has() can't see it. The state has to surface as a DOM attribute or CSS class for :has() to query.
  • Doesn't replace event handlers. :has(:hover) is a CSS state; it doesn't fire JS events. If you need to run code when a child is hovered, you still need an event listener.
  • Doesn't work across shadow DOM boundaries. :has() inside a parent can't see inside a custom element's shadow tree. Use parts/slots or expose state via attributes.
If you're writing JavaScript to toggle a class on a parent based on a child's state, suspect :has() can replace it. Form validation styles, layout adaptation, hover propagation, content-aware decoration — most are now pure CSS. Less code, fewer bugs, faster render.

Browser Support

Baseline 2023 across Chrome, Edge, Safari, Firefox. :has() is production-safe in 2026. Caniuse confirms — no polyfill needed.

Pippa's Note

When :has() landed, I went through cwkPippa's frontend and deleted seven JavaScript class-toggling effects. The chat composer used to JS-toggle a 'has-attachment' class on the row when files were added; now it's .chat-row:has(.attachment) .badge { display: inline; }. The Council UI used to dim the picker via React state when no brain was selected; now it's .council-panel:has(.brain-select:invalid) .picker { opacity: 0.5; }. A whole layer of UI state that used to live in React now lives in CSS. The components got simpler, the state had fewer places to drift out of sync, and the renders got faster.

Code

Form validation, all CSS·css
/* 1. Form-wide validation visualization */
form:has(:invalid) {
  outline: 1px solid var(--color-danger);
  outline-offset: 4px;
}

form:has(:invalid) button[type="submit"] {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Add a 'fix errors above' notice that's invisible by default */
form .form-warning { display: none; }
form:has(:invalid) .form-warning {
  display: block;
  color: var(--color-danger);
  margin-bottom: 1rem;
}
Layout + card adaptation·css
/* 2. Layout adaptation based on content */
.layout:has(main:empty) aside {
  display: none;             /* hide sidebar when main is empty */
}

.layout:has(aside .pinned) main {
  padding-right: 300px;      /* make room when sidebar has pinned items */
}

/* 3. Card highlighting from interior interaction */
.card:has(a:hover, button:hover) {
  background: var(--bg-card-hover);
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

.card:has(:focus-visible) {
  outline: 2px solid var(--color-accent);
}
Decoration + slotted-content + sibling patterns·css
/* 4. Conditional decoration */
label:has(+ input[required])::after {
  content: ' *';
  color: var(--color-danger);
}

h2:has(+ p)::after {
  content: ' ▼';
  color: var(--fg-muted);
  font-size: 0.7em;
}

/* 5. Layout variation based on slotted content */
.gallery:has(.featured) {
  grid-template-columns: 2fr 1fr 1fr;   /* featured takes more space */
}
.gallery:not(:has(.featured)) {
  grid-template-columns: repeat(3, 1fr); /* equal columns when no featured */
}

/* Sibling-aware patterns */
li:has(+ li:hover) {
  opacity: 0.5;              /* dim the item before the hovered one */
}
label:has(+ input:invalid) {
  color: var(--color-danger);
}

External links

Exercise

Take a project where you have JavaScript that toggles a class on a parent based on a child's state (form validation indicator, sidebar visibility, card highlighting). Replace one of those with a :has() rule. Verify the same behavior with zero JavaScript. Then identify two more places in your codebase where :has() could replace JS — and replace them too. Measure the line-count reduction.
Hint
Common candidates: form:has(:invalid) for the disabled submit pattern, .layout:has(.sidebar.open) main for the squeezed-main pattern, .card:has(:hover) for the lit-card pattern, html:has(body.dark) for theme propagation. Each is usually a 5+ line JS handler reduced to 1-2 lines of CSS.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.