:has() is the CSS feature people spent a decade calling impossible: a parent selector. Before it, CSS could style a descendant based on its ancestor, but never the other way round — there was no way to say "style this <div> differently because a specific child is present inside it." :has() closes that gap, and it does more than the "parent selector" nickname suggests: it's a general relational selector that can look at children, siblings, and combinations of conditions, then style the element it's attached to based on what it finds.
The 10 demos below are all real, clickable UI patterns — a selectable card, a validated form field, a table row that reacts to its own status cell, a CSS-only dropdown menu. Nothing here is decorative; every one is copy-paste-ready for an actual project.
Every embed also has a Fork & Edit button that opens the exact snippet, already running, in My Code — FWD Tools' free in-browser editor. Rename a class, delete a line from the selector, and watch the rule stop matching. That's a faster way to understand why a selector works than reading about it: change one thing, see what breaks, put it back.
The syntax, in one breath
selector:has(relative-selector) { /* styles for selector */ }
:has() takes a selector (or a comma-separated list of them) and matches the element it's attached to if that inner selector finds something relative to it — a descendant by default, or a sibling if you give it a combinator. It never styles the thing found inside the parentheses; it only ever decides whether the outer element matches. In demo #1 below, .planCard:has(input:checked) reads as "any .planCard that has a checked input somewhere inside it" — and the border, background, and checkmark that then get styled all belong to .planCard, never to the input itself.
Why every demo here is something you click, not drag
These aren't scrubbable sliders like a fluid-typography demo would be, because :has() doesn't respond to size — it responds to the DOM: a checkbox getting checked, an input becoming invalid, a child element being added or removed, a sibling being hovered. So each demo below is wired to whatever interaction actually triggers its rule: click a card, type in a field, hover a step, add a tile.
One thing worth knowing about how these are built: the JavaScript in every demo only ever reports what's happening into the small readout under each one — it counts checked boxes, reads a field's validity, counts child elements. It never sets the class or style that produces the visual effect. That part is left entirely to the :has() rule shown in the code tag below each demo, so what you're watching react is genuinely the CSS, not a script pretending to be one.
1. Selectable Plan Card
Click a pricing card and the whole card — border, background, checkmark — reacts to its own radio input being checked, no JavaScript required for the styling.
The rule:
.planCard:has(input:checked) {
border-color: var(--accent);
background: #eef0ff;
}
Before :has(), doing this meant either JavaScript toggling a class on click, or the "checkbox hack" — styling a sibling of a checked input with the ~ combinator, which only works if the thing you want to style happens to come after the input in the markup. Here the input is nested inside the card, and the whole card reacts — a layout ~ alone could never reach.
Best for: plan selectors, radio-card pickers, any UI where the whole card should look "selected," not just a small radio dot in the corner. Tip: because this is a native input under the hood, keyboard navigation (Tab, arrow keys, Space) works automatically — you get accessible selection for free.
2. Live Invalid-Field Highlight
Type an email address into the field — its wrapper turns red the moment the input becomes invalid, and green once it's a properly formatted address. No submit button, no JavaScript validation logic.
The rule:
.field:has(input:not(:placeholder-shown):invalid) {
border-color: #dc2626;
background: #fef2f2;
}
:invalid alone would flag this field red the instant the page loads, since an empty required field is invalid by definition — that would tell a visitor they've made a mistake before they've typed a single character. Chaining in :not(:placeholder-shown) withholds the red state until there's actually something in the field to be wrong. :has() is what lets that whole condition style the wrapper — the label and helper text included, not just the input's own border.
Best for: any form where you want validation feedback to feel immediate rather than waiting for a submit-and-scroll-to-the-error round trip. Tip: this is real, spec-defined :invalid matching — it respects whatever type, pattern, or required attributes are already on the input, so there's no separate validation logic to keep in sync.
3. "Contains a Badge" List Row
Click a notification to mark it read — its "NEW" badge is removed from the DOM, and the row's own bold styling and tinted background react to no longer containing one.
The rule:
.notifRow:has(.badge) {
background: #eef0ff;
font-weight: 700;
}
This is :has() in its purest form — not reacting to a state like :checked or :hover, just to whether a particular element is present at all. If your badge is added or removed by a real change in your data (an API response, a state update in a framework), this rule needs zero JavaScript to stay in sync: the moment the badge element exists or doesn't, the row's styling follows.
Best for: notification lists, inboxes, any row-based UI where an icon, badge, or flag buried inside a row should also change how the whole row reads at a glance. Tip: this same pattern works for "contains an error icon," "contains an attachment," or "contains a pinned marker" — anywhere a small piece of content should promote itself to a whole-row treatment.
4. Quantity-Aware Grid ("Quantity Query")
Add tiles one at a time — once the grid holds 6 or more, it switches itself to a denser 3-column layout. Remove tiles back below 6 and it reverts. No JavaScript sets that layout; only CSS counts.
The rule:
.qGrid:has(> .qTile:nth-child(6)) {
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
This is the trick known as a "quantity query": :nth-child(6) matches a 6th child if one exists, so :has(> .qTile:nth-child(6)) reads as "has a 6th tile child" — which is only true once there are at least six. The > restricts the check to direct children, so a tile nested somewhere deeper wouldn't accidentally count. Change nth-child(6) to nth-child(4) in the editor and the switch happens a lot sooner — a good first thing to try.
Best for: product grids, image galleries, dashboard tiles — any layout where a handful of items should get generous spacing but a full grid should tighten up rather than requiring an endless scroll. Tip: for "5 or more" instead of exactly 6, this exact pattern is what you want — there's no separate "at least N" selector, existence of the Nth child is the "at least N" check.
5. Gated Submit Button
The submit button sits visually disabled and genuinely unclickable until the terms checkbox is checked — driven entirely by :has() reaching from the form down to a sibling button.
The rule:
.gateForm:has(#terms:not(:checked)) .submitBtn {
opacity: .45;
pointer-events: none;
}
Notice the shape here: the condition is checked on the form (does it have an unchecked #terms?), and the styling lands on a completely different element, the button. :has() is what makes the form a legal place to ask that question at all — without it, you'd need JavaScript watching the checkbox and toggling a disabled attribute on the button by hand.
Best for: terms-of-service gates, "I've reviewed this" confirmations, any consent checkbox that should visibly and functionally block a next step. Tip: pointer-events: none is what makes this a real block, not just a visual one — opacity alone would still let a determined click through.
6. Status-Highlighted Table Row
Click "Cycle status" on any row — the whole row's background reacts to whichever status cell it currently contains, cycling between on-track, overdue, and failed.
The rule:
tr:has(td.status-overdue) { background: #fef2f2; }
tr:has(td.status-error) { background: #fff7ed; }
Tables are a place this pattern earns its keep immediately: a status, a severity level, or a flag usually lives in one column's cell, but a reader scanning the table benefits from the whole row carrying that signal. Before :has(), this meant a server or a script adding a class like row-overdue to the <tr> itself, duplicating information that was already sitting one cell over.
Best for: admin dashboards, order lists, any tabular data where a status column should be scannable from across the room, not just readable up close. Tip: list your status rules in priority order — if a row could match more than one (say, both "overdue" and "error"), whichever :has() rule is defined later in the stylesheet wins under equal specificity.
7. Step Progress Hover Preview
Hover any step in the tracker — it and every step before it light up as a preview. Selecting a "previous sibling" like this was flatly impossible in CSS before :has() existed.
The rule:
.step:has(~ .step:hover),
.step:hover {
background: var(--accent);
color: #fff;
}
This is the demo that best shows off what's genuinely new here. ~ is the general sibling combinator, and it has always only been able to look forward — "a .step that is followed by a hovered .step" was writable for two decades. But it could only style the later element. :has(~ .step:hover) flips the direction: it asks each step "do any of my later siblings currently have :hover?" — and answers that question about itself, which is exactly what makes the earlier steps light up. This exact "select the previous sibling" gap is the single most-requested CSS feature :has() resolved.
Best for: checkout/order-status trackers, multi-step wizards, breadcrumb-style progress bars where hovering a later step should preview "everything up to here." Tip: this pattern works identically for a star rating widget — swap the steps for star icons and :hover for a mix of :hover and :checked radios.
8. CSS-Only Dropdown Menu
Click the menu button — no JavaScript opens this. A hidden checkbox toggles, and :has() lets the wrapper react to it no matter how deeply the menu itself is nested inside.
The rule:
.ddWrap:has(#ddToggle:checked) .ddMenu {
display: block;
}
The classic "checkbox hack" for a no-JS dropdown relies on the sibling combinator too, which quietly constrains your markup: the toggle and the thing it controls have to be siblings, or the trick breaks. :has() removes that constraint — the wrapper is checking "do I contain a checked #ddToggle anywhere," so the checkbox, the button, and the menu can be nested however your markup actually needs them to be.
Best for: lightweight menus, filter panels, or any toggle-driven UI in a static site or email-safe context where you'd rather not reach for JavaScript at all. Tip: real apps almost always still want the JavaScript too — for closing the menu on Escape, or on an outside click, which is exactly what this demo's own JS quietly does (it only ever unchecks the box; the CSS still decides what that means visually).
9. Empty-State Message
Clear every task — the "you're all caught up" message appears the instant the list has no items left, and disappears the moment you add one back.
The rule:
.taskList:not(:has(li)) + .emptyMsg {
display: block;
}
:not(:has(li)) matches a list with zero matching <li> elements — which is a more reliable "is this empty" check than the :empty pseudo-class, since :empty also gets defeated by so much as a stray whitespace text node between tags (a near-guarantee in real, human-formatted HTML). Notice this uses a plain + adjacent-sibling combinator to reach the message, rather than a second :has() wrapped around the first — see the pitfall below for why.
Best for: to-do lists, search results, inbox views, cart pages — anywhere "nothing here" deserves an intentional message instead of a blank space that reads like the page is broken. Tip: pair this with a matching "results: N" counter elsewhere on the page (as this demo's readout does), so the empty state and the count never have separate logic to fall out of sync.
10. Favorite Toggle Gallery Card
Click the heart on any card — unlike the plan-card demo, these are independent checkboxes rather than a mutually exclusive radio group, so any number of cards can be favorited at once.
The rule:
.galleryCard:has(.likeBox:checked) {
border-color: gold;
box-shadow: 0 0 0 3px #fef9c3;
}
Structurally this is the same shape as demo #1 — :has(input:checked) — but the meaning is different because the input type is different. Radios enforce mutual exclusion for free; checkboxes don't, so this same one-line pattern quietly becomes a multi-select favorites system instead of a single-select picker, with no extra CSS or JS needed to manage the "only one at a time" logic, because there isn't any.
Best for: image galleries, product grids, bookmarking UI — anywhere "liked" or "saved" needs to be an independent per-item toggle rather than a single choice. Tip: the readout under this demo counts checked boxes with document.querySelectorAll('.likeBox:checked').length — a good reminder that even though the visual state is pure CSS, reading that state back out for a "3 favorited" counter is a completely normal, honest use of a few lines of JavaScript.
Common :has() pitfalls
- :has() cannot contain another :has() — even through a :not(). The CSS spec explicitly disallows nesting: the selector inside
:has()is not allowed to contain a second:has()anywhere within it, including tucked inside a:not()..wrap:has(.list:not(:has(li)))is invalid CSS and the whole rule is silently ignored — no console error, it just never matches. Demo #9 above hit exactly this while it was being built; the fix was to drop the outer:has()and reach the sibling with a plain+combinator instead. - :has() isn't specificity-free — and it can end up more specific than it looks. The selector inside the parentheses counts toward the specificity of the whole rule, in full:
tr:has(td.status-overdue)is actually more specific than a simpler-lookingtr.status-overdue, because thetdtype selector inside:has()adds its own weight on top of the class — it beats it even when the plain class rule is declared later, which normally would be enough to win a tie. If a:has()rule refuses to lose to something you expected to override it, this is usually why. - With no combinator, :has() matches at any depth — not just direct children.
.card:has(.icon)matches whether.iconis an immediate child or buried five levels deep; the implicit combinator is the same "any descendant" one a plain space means everywhere else in CSS. If you specifically need "a direct child," write:has(> .icon)the way demo #4's quantity query does — leaving the>out there would let a stray.qTilenested inside some other element count toward the total by accident. - Well supported, but always worth a quick check for your audience. Every evergreen browser (Chrome, Edge, Safari, Firefox) has shipped
:has()since late 2023. If your project needs to support meaningfully older browsers, check caniuse.com/css-has for exact minimum versions before relying on it for anything load-bearing. - :has() can be expensive on deeply nested trees. Unlike most CSS selectors, which the browser can often reason about without walking much of the DOM,
:has()may need to inspect a whole subtree to decide whether an element matches. For most real UI — cards, form fields, table rows — this is a non-issue. It's worth being deliberate about it only if you're applying a:has()rule broadly across a very large, deeply nested page.
Frequently asked questions
What is the CSS :has() selector?
:has() is a CSS pseudo-class that matches an element based on what's found relative to it — a descendant by default, or a sibling with a combinator — rather than based on the element's own attributes or state. It's often called a "parent selector" because the most common use is styling an ancestor based on one of its children, but it also works for sibling relationships, as in the step-tracker hover-preview demo above.
What is the syntax of :has()?
selector:has(relative-selector). The relative selector can start with a combinator — > for a direct child only, ~ or + for a sibling — or omit one entirely, which defaults to matching any descendant at any depth. It also accepts a comma-separated list, matching if any one of the listed conditions is found.
Can I nest :has() inside :has()?
No — the CSS spec explicitly forbids it, and that restriction applies even if the inner :has() is wrapped inside a :not(). A selector like .wrap:has(.list:not(:has(li))) is invalid and the whole rule is ignored. Reach for a sibling combinator (+ or ~) instead when you find yourself wanting to nest, as demo #9's empty-state pattern does above.
Does :has() work with the :not() pseudo-class?
Yes — :has(x:not(y)) and :not(:has(x)) are both valid and genuinely useful; several demos above rely on exactly this combination, including the gated-submit-button (:has(#terms:not(:checked))) and the empty-state message (:not(:has(li))). The one thing :not() can't do for you inside a :has() is contain a second :has() — see the pitfall above.
Is :has() well supported in browsers?
Yes, in every evergreen browser as of late 2023 — Chrome, Edge, Safari, and Firefox all ship it. If your project has to support older or less common browsers, verify the exact cutoff on caniuse.com/css-has before depending on it for essential functionality.
Can :has() replace JavaScript for interactive styling?
For a large class of "style something based on a checkbox, an input's validity, or the presence of another element" interactions, yes — every demo above does exactly that with zero JavaScript driving the visual result. It doesn't replace JavaScript outright, though: anything that needs to read state back out (a "3 selected" counter), persist a choice, or run real application logic still needs script, same as it always has.
Every demo above ships its full HTML, CSS and JS in the HTML / CSS / JS tabs in its own top bar. But the better way to actually learn this is to click Fork & Edit instead of just copying: it hands you the same snippet in a live editor where you can rename a class, delete a combinator, or swap a selector and watch exactly what stops working. Once you've got a version you like, save it to My Code and it's yours to keep, tweak further, or reuse in a real project.
