CSS :has() is not just a fancy :parent
When :has() started popping up in specs and tweets, I mentally filed it under “cool, but not for shipping work.” I was wrong.
Now it is in Chrome, Safari, Edge, and Firefox. I use it in real projects. It has removed entire JavaScript files and a pile of .is-active classes that I was embarrassed to maintain.
If you are a working frontend dev, the shorthand is this: :has() turns CSS from “style what is there” into “style this thing if it contains that thing”. That one capability changes layout, state, and validation flows.
I will walk through three places where it made a real difference for me:
- Parent styling without JS
- Sibling state UIs without wiring events
- Form validation UI that reacts to the DOM, not a framework
All of this shipped with zero additional JavaScript.
Quick mental model of :has()
The syntax looks like a pseudo class on a selector:
.card:has(img.hero) {
/* styles here */
}
Read it as: “select .card elements that have a descendant img.hero somewhere inside.” It is a conditional filter on the left side of the selector.
You can also scope it more tightly:
.tabs:has(> .tab.is-active) {
/* direct children only */
}
Or use it with relational selectors like siblings:
.field:has(+ .field--error) {
/* this .field is followed by an error field */
}
Once that clicks, you start seeing places to remove JS.
1. Parent styling in a content-heavy project
First real use: a content-heavy marketing site for a biohacking brand I work with. Editors can drop components in any order with a CMS. Sometimes a card has an image, sometimes it is text-only. The layout should adapt.
Previously I solved this with modifier classes from the CMS, or a hydration script that scans the DOM and adds classes like .card--with-media. Boring, fragile, and slightly gross.
With :has() I deleted that script.
Image-aware cards
The card markup is boring on purpose:
<article class="card">
<img class="card__media" src="hero.jpg" alt="">
<div class="card__body">
<h2>Title</h2>
<p>Some text...</p>
</div>
</article>
<article class="card">
<div class="card__body">
<h2>Another Card</h2>
<p>Text-only card.</p>
</div>
</article>
Now the CSS decides layout based on presence of media.
.card {
display: grid;
gap: 1rem;
}
.card:has(.card__media) {
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
align-items: center;
}
.card:not(:has(.card__media)) {
padding: 2rem;
background: #111;
color: #eee;
}
Result: if marketing drops in an image, the card becomes a two-column layout. If not, it becomes a full-width text block. No new class. No CMS configuration. No JS.
I like this because the markup stays semantic and dumb. The layout is a true function of the content, which is what CSS was always supposed to do but rarely could at the parent level.
Auto-promoting “hero” sections
Same project. Editors could add a .section stack: some had a prominent CTA, some were just copy. If a section had a primary CTA, design wanted extra padding and a gradient background.
<section class="section">
<h2>Get early access</h2>
<p>Short description.</p>
<a class="btn btn--primary" href="#">Join the beta</a>
</section>
<section class="section">
<h2>What you get</h2>
<p>More text...</p>
</section>
With :has() I treat any section with a primary button as a pseudo hero.
.section {
padding: 2rem 1.5rem;
background: #050505;
}
.section:has(.btn--primary) {
padding: 4rem 1.5rem;
background: radial-gradient(circle at top, #2f80ed, #050505);
color: #fff;
}
.section:has(.btn--primary) h2 {
font-size: 2.25rem;
}
That tiny selector replaced a custom “hero” block type in the CMS that content editors kept misusing. I stopped explaining “use the hero component for this” and just let the CSS infer intent from presence of a primary CTA.
You can do similar things with :has(video), :has(.badge--new), etc. It is a good fit for messy CMS content where you want layout to respond to what your editors actually do, not what the schema designer hoped they would do.
2. Sibling state UIs without event listeners
Second use case: stateful UIs that I used to wire up with click handlers. Tabs, disclosure panels, navigation highlights, that stuff.
Yes, you can still do it in JS. But if the state is already visible in the DOM, :has() lets CSS own more of the behavior. That means less code, fewer states to sync, and fewer bugs.
Tabs powered by :target and :has()
On a little side project for baseball drills, I built a tabbed interface where each tab is actually a link to an anchor. I wanted a sticky tab bar that changes style when any tab content is active.
<div class="tabs">
<nav class="tabs__nav">
<a href="#hitting">Hitting</a>
<a href="#pitching">Pitching</a>
<a href="#fielding">Fielding</a>
</nav>
<section id="hitting" class="tabs__panel">...</section>
<section id="pitching" class="tabs__panel">...</section>
<section id="fielding" class="tabs__panel">...</section>
</div>
The panels show / hide with a regular :target trick.
.tabs__panel {
display: none;
}
.tabs__panel:target {
display: block;
}
Old me would now add JS to toggle classes on the nav. Instead I lean on :has().
.tabs {
border-bottom: 1px solid #333;
}
.tabs__nav a {
padding: .5rem 1rem;
text-decoration: none;
color: #888;
}
.tabs__nav a:is(:hover, :focus-visible) {
color: #fff;
}
/* highlight the active tab label */
.tabs__nav a[href^="#"] {
position: relative;
}
.tabs:has(#hitting:target) .tabs__nav a[href="#hitting"],
.tabs:has(#pitching:target) .tabs__nav a[href="#pitching"],
.tabs:has(#fielding:target) .tabs__nav a[href="#fielding"] {
color: #fff;
font-weight: 600;
}
/* make the whole tabs block look active if any panel is targeted */
.tabs:has(.tabs__panel:target) {
border-color: #2f80ed;
}
I am not pretending this scales to 50 tabs. For most content UIs, 3 to 5 tabs is realistic. Writing those few selectors is still cheaper than adding a tab manager, handling history state, and worrying about hydration.
The key pattern is: some child panel already has state via :target or [aria-selected="true"]. Let :has() bubble that state up to parents and siblings.
Accordion with native <details> and :has()
I use <details> a lot. It is surprisingly powerful with :has(). On a settings panel I wanted the container to visually compress when no section was open, then expand once any accordion entry was open.
<section class="settings">
<details class="settings__item">
<summary>Profile</summary>
<div>...</div>
</details>
<details class="settings__item">
<summary>Privacy</summary>
<div>...</div>
</details>
</section>
CSS:
.settings {
padding: 1rem;
border-radius: .75rem;
border: 1px solid #333;
max-height: 60vh;
overflow: auto;
transition: box-shadow .2s ease, border-color .2s ease;
}
.settings:has(.settings__item[open]) {
border-color: #2f80ed;
box-shadow: 0 16px 40px rgba(0, 0, 0, .55);
}
.settings__item + .settings__item {
border-top: 1px solid #222;
}
.settings__item summary {
cursor: pointer;
}
Once any <details> is open, the whole settings block feels “in focus”. No JS to listen for the toggle event, no syncing of .is-active classes. The HTML already has [open]. CSS reacts.
3. Form validation UI with zero JavaScript
The biggest win for me: form UI that uses :has() with built-in browser validation. No client-side validation library. No “touched” state juggling.
On my own site I revamped a contact form and a simple experiment log form. I wanted:
- Parent field wrappers that highlight error or success
- Inline messages that only show when actually invalid
- Submit button that changes state based on form validity
Browser validation already tracks validity. The DOM knows. :has() lets CSS hook into that.
Field states from input validity
Markup:
<form class="form" novalidate>
<div class="field">
<label>
Email
<input type="email" name="email" required>
</label>
<p class="field__error">Please enter a valid email.</p>
</div>
<div class="field">
<label>
Message
<textarea name="message" minlength="10" required></textarea>
</label>
<p class="field__error">Write at least 10 characters.</p>
</div>
<button type="submit">Send</button>
</form>
You can bind field styling to the input inside, purely with CSS.
.field {
margin-bottom: 1.5rem;
}
.field input,
.field textarea {
width: 100%;
padding: .6rem .75rem;
border-radius: .4rem;
border: 1px solid #444;
background: #050505;
color: #eee;
}
.field__error {
display: none;
margin-top: .35rem;
font-size: .8rem;
color: #ff6b6b;
}
/* highlight when invalid and touched (using :user-invalid where supported) */
.field:has(input:user-invalid),
.field:has(textarea:user-invalid) {
color: #ff6b6b;
}
.field:has(input:user-invalid) input,
.field:has(textarea:user-invalid) textarea {
border-color: #ff6b6b;
box-shadow: 0 0 0 1px rgba(255, 107, 107, .6);
}
.field:has(input:user-invalid) .field__error,
.field:has(textarea:user-invalid) .field__error {
display: block;
}
/* success state */
.field:has(input:user-valid),
.field:has(textarea:user-valid) {
color: #4caf50;
}
.field:has(input:user-valid) input,
.field:has(textarea:user-valid) textarea {
border-color: #4caf50;
}
No custom event handlers. The browser decides when the input is valid or invalid. CSS uses :has() to move that state to the wrapper and the message.
If you want broader support than :user-invalid, you can fall back to :invalid and accept that some browsers show the state earlier.
Form-level feedback and submit button state
Now zoom out one level. The entire <form> element also exposes validity via :valid and :invalid. Combine that with :has() and your submit button can react.
.form button[type="submit"] {
padding: .7rem 1.25rem;
border-radius: .4rem;
border: none;
background: #333;
color: #aaa;
cursor: not-allowed;
transition: background .15s ease, color .15s ease, transform .05s;
}
/* any invalid field keeps button in "disabled" style */
.form:has(:invalid) button[type="submit"] {
background: #333;
color: #777;
}
/* all fields valid, button goes live */
.form:has(:valid) button[type="submit"] {
background: #2f80ed;
color: #fff;
cursor: pointer;
}
.form:has(:valid) button[type="submit"]:active {
transform: translateY(1px);
}
If you want to actually disable the button, you still need a tiny bit of JS to toggle the disabled attribute. I usually do not bother for simple forms; the button just looks inactive until the browser considers the form valid.
The nice part is that the logic lives where it belongs. The browser enforces constraints. CSS reads that state. JS, if present at all, sends the request and displays a toast.
4. Layout tweaks based on children, not breakpoints
One more pattern that has crept into my “default toolkit”: adjusting layout based on how many items a container has.
On my baseball drills page, each drill has one or more tags. I wanted single-tag drills to show the tag inline next to the title, and multi-tag drills to move them into a separate row. Doing that in JS felt silly.
<article class="drill">
<header class="drill__header">
<h3 class="drill__title">Front toss</h3>
<div class="drill__tags">
<span class="tag">Hitting</span>
</div>
</header>
</article>
<article class="drill">
<header class="drill__header">
<h3 class="drill__title">Relay race</h3>
<div class="drill__tags">
<span class="tag">Fielding</span>
<span class="tag">Conditioning</span>
</div>
</header>
</article>
With :has() and the :nth-child() selector you can treat the two cases differently.
.drill__header {
display: flex;
gap: .5rem;
align-items: baseline;
flex-wrap: wrap;
}
/* one tag only: keep inline */
.drill__tags:has(.tag:nth-child(1):last-child) {
order: 0;
}
/* more than one tag: push tags to next line */
.drill__tags:has(.tag:nth-child(2)) {
flex-basis: 100%;
order: 1;
}
No JavaScript counting nodes. No data attributes. Just “if there is at least a second tag, change layout”. If product decides to add a third or fourth tag, the CSS keeps working.
Reality check: performance and support
I am not going to pretend :has() is free. The browser has to do more work, because selectors now depend on what is inside elements and how that changes.
My take after profiling a few real pages: do not go wild with global *:has(...) selectors. Scope them. Prefer direct children or close relationships.
/* Bad idea */
*:has(.error) { ... }
/* Reasonable */
.form:has(.field__error) { ... }
/* Even better */
.form:has(.field > .field__error) { ... }
Support is good now. Chrome, Edge, Safari, Firefox all ship :has(). Old Safari versions are the main risk. If you work on something critical for a weird enterprise fleet, check caniuse and add progressive enhancement.
Most of my patterns above fail gracefully. You lose a highlight or a layout tweak, not core functionality. That is a good bar to aim for.
How I think about :has() now
I used to reach for JavaScript whenever a parent needed to know about a child, or a sibling needed to react to state. That felt normal. It also created a lot of glue code that did not age well.
Now my filter is simple:
- Is the state already visible in the DOM? (attribute, pseudo class, anchor, etc.)
- Can that state reasonably drive styling only?
If the answer is yes, I try :has() first. JS comes later, if at all.
Five years ago I was writing tab managers and form validators by hand. I would not go back. :has() is the layout trick that finally lets CSS act on the structure we already have, instead of the utility classes we wish we had planned better.
If you have a component that keeps growing event listeners and state flags, look at the HTML for five minutes. There is a decent chance :has() can take some of that weight off.
Subscribe to my newsletter to get the latest updates and news
Member discussion