8 CSS :has() Patterns You'll Actually Use (2026)
CSS :has() is production-ready in every browser. Here are 8 real-world patterns: form states, sibling dimming, modal scroll-lock, and more.
On this page
For years, CSS could not style a parent based on what was inside it. If you wanted to highlight a form row when its input had an error, you needed JavaScript to add a class to the parent. That's over. :has() is in every major browser, it's production-ready, and once you see what it can do you'll wonder how you wrote CSS without it.
This post skips the syntax tour (you can read that on MDN). Instead: 8 patterns worth keeping close, each with HTML structure, copy-paste CSS, and the real-world problem it solves.
- 1
Style a Form Group When Its Input Is Invalid
The classic problem: you want the label, border, and error state on a
.form-groupto turn red when the<input>inside it fails validation. Before:has(), this required JavaScript to add a class to the parent element.html — HTML structure<div class="form-group"> <label for="email">Email</label> <input id="email" type="email" required /> <span class="error-msg">Invalid email address</span> </div>css — CSS.form-group:has(input:invalid) { border-color: #e53e3e; background-color: #fff5f5; } .form-group:has(input:invalid) label { color: #e53e3e; } .form-group:has(input:invalid) .error-msg { display: block; } - 2
Highlight a Nav Item Containing the Active Link
You have a
<li>wrapping each<a>in your navigation. You want the entire<li>to appear active. Without:has(), you'd need the active class on the<li>, not the<a>(awkward with React Router and Next.js<Link>).html — HTML structure<nav> <ul> <li><a href="/blog" class="active">Articles</a></li> <li><a href="/tools">Tools</a></li> </ul> </nav>css — CSSnav li:has(a.active) { background-color: #ebf8ff; border-radius: 6px; } nav li:has(a.active) a { font-weight: 600; color: #2b6cb0; } - 3
Cards That Adapt Layout to Their Own Content
Cards with images need a different layout than text-only cards. Previously this required JavaScript at render time or separate component variants with different class names.
html — HTML structure<!-- Card with image (gets grid layout) --> <div class="card"> <img src="..." alt="..." /> <div class="card-body">...</div> </div> <!-- Text-only card (gets centered layout) --> <div class="card"> <div class="card-body">...</div> </div>css — CSS.card { padding: 1.25rem; border-radius: 8px; border: 1px solid #e2e8f0; } .card:has(img) { display: grid; grid-template-columns: 180px 1fr; gap: 1rem; } .card:not(:has(img)) { max-width: 420px; text-align: center; } - 4
Lock Body Scroll When a Modal Is Open
One line. No JavaScript. The moment a
<dialog>element with theopenattribute exists anywhere in the page, scrolling stops. When the dialog closes, scrolling returns automatically.html — HTML structure<dialog id="my-modal"> <p>Modal content</p> <button onclick="document.getElementById('my-modal').close()">Close</button> </dialog>css — CSSbody:has(dialog[open]) { overflow: hidden; } - 5
Dim All Sibling Cards Except the Hovered One
Hover over a card in a grid and the other cards visually recede. Previously this required JavaScript mouseover event handlers on every card.
html — HTML structure<div class="grid"> <div class="card">Card 1</div> <div class="card">Card 2</div> <div class="card">Card 3</div> </div>css — CSS.grid:has(.card:hover) .card:not(:hover) { opacity: 0.5; transform: scale(0.98); transition: opacity 150ms ease, transform 150ms ease; } - 6
Style a Label When Its Checkbox Is Checked
Custom checkbox styling without JavaScript. Two HTML patterns (input nested inside label, or input and label as siblings) each has the right CSS approach:
html — HTML: input nested inside label<label> <input type="checkbox" /> Remember me </label>css — CSS: use :has() for nested inputlabel:has(input[type="checkbox"]:checked) { font-weight: 600; color: #2b6cb0; text-decoration: line-through; }html — HTML: input and label as siblings<input id="remember" type="checkbox" /> <label for="remember">Remember me</label>css — CSS: use adjacent sibling for sibling labelinput[type="checkbox"]:checked + label { color: #2b6cb0; } - 7
Different Grid Columns Based on Item Count
Three columns when there are many items, a centered single column when there are only one or two. The grid reads its own children and adjusts: quantity queries in pure CSS.
html — HTML structure<ul class="grid"> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>css — CSS/* Three-column layout when at least 3 items exist */ .grid:has(:nth-child(3)) { grid-template-columns: repeat(3, 1fr); } /* Single centered column when fewer than 3 items */ .grid:not(:has(:nth-child(3))) { grid-template-columns: 1fr; max-width: 400px; margin: 0 auto; } - 8
Show a Clear Button Only When the Input Has Content
A text input with an inline clear button that only appears when the field contains text.
:placeholder-shownis false when the input has a value, no JavaScript needed.html — HTML structure<div class="input-wrapper"> <input type="text" placeholder="Search..." /> <button class="clear-btn" aria-label="Clear search">✕</button> </div>css — CSS.input-wrapper .clear-btn { display: none; } .input-wrapper:has(input:not(:placeholder-shown)) .clear-btn { display: flex; align-items: center; }
Driving Form Validation Entirely From CSS
Pattern 1 covered the single-field case. Real forms need more than that: a submit button that reacts to the form as a whole, error text that stays quiet until the user has actually tried something, and conditional fields that appear only when a particular option is picked.
All of it is reachable with :has() plus the validation pseudo-classes that HTML already ships. No validation library, no state, no event listeners for the visual layer.
Start with the pseudo-classes themselves, because picking the wrong one is the most common mistake. :invalid reflects constraint validation the instant the page loads, which is why a form full of empty required fields lights up red before the user types a character. :user-invalid only applies after the user has interacted with the control and left it.
| Pseudo-class | Matches when | Use it for |
|---|---|---|
| :invalid | Value fails constraint validation, including on first paint | Form-level checks like disabling a submit affordance |
| :user-invalid | Fails validation and the user has already interacted | Per-field red borders and inline error text |
| :valid | Value passes constraint validation | Rarely useful alone: matches empty optional fields too |
| :user-valid | Passes validation after user interaction | Green checkmarks that only appear once earned |
| :required | Control has the required attribute | Rendering an asterisk on the label with no extra markup |
| :placeholder-shown | Input is empty and its placeholder is visible | Floating labels and conditional clear buttons |
| :checked | Checkbox, radio, or option is selected | Selection cards, toggles, bulk-action bars |
| :indeterminate | Checkbox set indeterminate in JS, or radio group untouched | Tri-state select-all checkboxes |
Once you have the right pseudo-class, :has() lifts it up to any ancestor you want. The form element itself is the most useful target, because it can see every control at once.
/* Fade the submit affordance while any control in the form is invalid */
form:has(:invalid) button[type="submit"] {
opacity: 0.5;
cursor: not-allowed;
}
/* Error summary appears only after the user has actually tried a field */
form:has(:user-invalid) .error-summary {
display: block;
}
/* Confirmation state once nothing in the form is invalid */
form:not(:has(:invalid)) .form-status {
color: #276749;
}
/* Mark required labels without touching the markup */
.form-group:has(input:required) label::after {
content: " *";
color: #e53e3e;
}That distinction matters more than it looks. A form that is visually "locked" but silent is a common accessibility regression, and it is the kind of thing that only shows up in a screen reader pass. Our guide to ARIA in React covers how to pair these CSS states with the announcements that assistive technology actually needs.
Conditional fields are the other half of this. The instinct is to write a rule that shows the extra field when an option is checked, but the negative form is better.
/* Radio card highlights itself when the input inside it is selected */
.radio-card:has(input[type="radio"]:checked) {
border-color: #2b6cb0;
box-shadow: 0 0 0 2px #bee3f8;
}
/* Follow-up field is hidden unless the "other" option is picked */
.shipping-options:not(:has(input[value="other"]:checked)) .other-details {
display: none;
}
/* Dim an entire row when its control is disabled */
.form-row:has(input:disabled) {
opacity: 0.6;
pointer-events: none;
}
/* Tri-state select-all in a table header */
thead:has(input:indeterminate) .select-all-hint {
display: inline;
}Writing the hide rule as a negation means the field is visible by default and gets hidden only when the browser understands :has(). If the rule is ever dropped (an old browser, a stylesheet that failed to load, a parse error earlier in the file), the user sees an extra optional field rather than a field that is permanently unreachable.
That is the general shape of safe progressive enhancement with :has(): make the enhanced state the one that hides or restricts, never the one that reveals something essential.
Quantity Queries and Child-Type Queries
Pattern 7 touched on counting children. It is worth going deeper, because quantity queries are one of the few places where :has() does something that was genuinely impossible before, not just inconvenient.
The old technique used :nth-last-child with a general sibling combinator. It works, and it still works today, but it can only style the children. The container itself was untouchable.
/* When there are 5 or more items, shrink every item.
Note what is missing: no way to change the list itself. */
.tag-list > li:nth-last-child(n + 5),
.tag-list > li:nth-last-child(n + 5) ~ li {
font-size: 0.8rem;
padding-inline: 0.4rem;
}With :has(), the container is the subject and the count moves inside the parentheses. Every variation you need falls out of :nth-child combined with negation.
/* 5 or more children */
.tag-list:has(> li:nth-child(5)) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
/* Exactly 2 children */
.tag-list:has(> li:nth-child(2)):not(:has(> li:nth-child(3))) {
display: flex;
justify-content: space-between;
}
/* Fewer than 4 children */
.tag-list:not(:has(> li:nth-child(4))) {
max-width: 32rem;
margin-inline: auto;
}
/* No children at all */
.tag-list:not(:has(> li)) {
display: none;
}
/* An odd number of children, if you need the last one to span */
.tag-list:has(> li:last-child:nth-child(odd)) > li:last-child {
grid-column: 1 / -1;
}| You want | Selector | Reads as |
|---|---|---|
| At least N | .list:has(> li:nth-child(N)) | an Nth child exists, so there are at least N |
| Fewer than N | .list:not(:has(> li:nth-child(N))) | no Nth child exists |
| Exactly N | .list:has(> li:nth-child(N)):not(:has(> li:nth-child(N+1))) | an Nth exists but an N+1th does not |
| Empty | .list:not(:has(> li)) | no matching child at all |
| Only one | .list:has(> li:only-child) | the single child is also the last |
| Odd count | .list:has(> li:last-child:nth-child(odd)) | the last child sits at an odd index |
The same mechanism answers a different question: not how many children, but what kind. This is where content-driven components stop needing variant props.
/* Video cards get a fixed aspect ratio, image cards do not */
.card:has(> video) {
aspect-ratio: 16 / 9;
}
/* A card carrying both media and a heading needs more breathing room */
.card:has(> img):has(> h3) {
padding-block: 1.25rem;
}
/* Any heading level, without repeating the rule */
.card:has(> :is(h2, h3, h4)) {
padding-top: 0.5rem;
}
/* Figures behave differently once they carry a caption */
figure:has(> figcaption) {
margin-block-end: 2rem;
}
/* Layout grid reacts to whether a sidebar was actually rendered */
.layout:has(> aside) {
display: grid;
grid-template-columns: 1fr 18rem;
gap: 2rem;
}Prefer the direct child combinator (> ) whenever you know the structure. It is more precise, it documents the expected DOM, and it gives the browser a much smaller subtree to consider. A bare :has(img) matches an image nested ten levels down inside an embedded widget you did not write.
Combining :has() With :not() and What It Does to Specificity
:has() and :not() follow the same specificity rule: neither pseudo-class contributes anything itself, and both take the specificity of the most specific selector in their argument list.
That sounds simple until a single ID inside a :has() list quietly turns a low-specificity rule into one nothing can override.
| Selector | Specificity | Why |
|---|---|---|
| .card:has(img) | 0,1,1 | one class plus one element |
| .card:has(.badge) | 0,2,0 | two classes |
| .card:not(:has(img)) | 0,1,1 | :not() inherits the weight of :has(img) |
| li:has(a[aria-current="page"]) | 0,1,2 | element, element, attribute selector |
| .card:has(.badge, #promo) | 1,1,0 | the ID wins the argument list, even when the match came from .badge |
| .card:has(:where(.badge, #promo)) | 0,1,0 | :where() zeroes its contents |
The fifth row is the trap. Adding one ID to a selector list inside :has() raises specificity for every element the rule matches, including the ones matched by the class. If you need a mixed list, wrap it in :where() and keep the weight flat and predictable.
/* Specificity 1,1,0 for everything this matches: hard to override later */
.card:has(.badge, #promo) {
border-color: #2b6cb0;
}
/* Specificity 0,1,0: same match, much easier to live with */
.card:has(:where(.badge, #promo)) {
border-color: #2b6cb0;
}If you find yourself guessing at these numbers, run the selector through the CSS specificity calculator: it breaks a selector into its ID, class, and element counts and shows exactly which argument set the weight. You can also try any of the selectors in this article live in the CSS :has() playground.
Nesting :not() inside :has() is legal and occasionally the cleanest way to express a whole-subtree condition. It is also the fastest way to write a selector nobody can read six months later.
/* Gallery whose descendants are all images and nothing else */
.gallery:not(:has(:not(img))) {
padding: 0;
gap: 2px;
}
/* Row that contains at least one checked box but no disabled one */
tr:has(input:checked):not(:has(input:disabled)) {
background-color: #ebf8ff;
}Pattern Quick Reference
| Problem | Selector pattern | Tailwind v4 equivalent |
|---|---|---|
| Style form row on invalid input | .form-group:has(input:invalid) | has-[input:invalid]:border-red-500 |
| Active nav item wrapper | li:has(a[aria-current='page']) | has-[a[aria-current='page']]:bg-blue-50 |
| Card layout with/without image | .card:has(img) | has-[img]:grid |
| Lock scroll when modal open | body:has(dialog[open]) | has-[dialog[open]]:overflow-hidden |
| Dim siblings on hover | .grid:has(.card:hover) .card:not(:hover) | N/A (custom CSS) |
| Checked checkbox label | label:has(input:checked) | has-[input:checked]:font-semibold |
| Grid columns by item count | .grid:has(:nth-child(3)) | N/A (custom CSS) |
| Show clear button when input filled | .wrapper:has(input:not(:placeholder-shown)) .btn | N/A (custom CSS) |
Performance Considerations
- Avoid
body:has(:hover): it recalculates styles on every mousemove across the entire page. Scope to the specific container instead. - Prefer direct child selectors (
:has(> .child)) over descendant selectors (:has(.child)) when you know the exact DOM structure. It's faster and more explicit. - Avoid deeply chained
:has()inside:has(): browsers handle it, but readability suffers quickly.
Frequently Asked Questions
Do I still need JavaScript for any of these patterns?
No: all 8 patterns in this post work in pure CSS. The modal scroll-lock, sibling dimming, and form validation styling all previously required JavaScript event listeners. :has() removes that dependency. You still need JavaScript for behavior (opening/closing a modal, submitting a form), but CSS now handles the visual response.
Can I use :has() with Tailwind CSS?
Yes. Tailwind v3.4+ added the has-* variant. For simple patterns you can write utility classes directly:
<!-- Tailwind v4 has-* variant -->
<div class="has-[input:invalid]:border-red-500 has-[input:invalid]:bg-red-50">
<input type="email" required />
</div>For complex patterns like sibling dimming or quantity queries, write custom CSS in a @layer alongside Tailwind: they work side by side.
Is there a performance cost to using :has()?
For typical UI patterns, no: performance is comparable to other complex selectors. The risk is with overly broad selectors on the document root (like body:has(:hover)), which force the browser to recalculate styles across the entire document on every match. Scope :has() to specific containers and prefer direct child selectors when possible.
How does :has() affect CSS specificity?
:has() itself adds no specificity: specificity comes from the selector inside it. .card:has(img) has the same specificity as .card img (0,1,1). .card:has(.featured) has 0,2,0 (two classes). This is lower than you might expect, which makes :has() styles easy to override.
Is :has() safe to use without a fallback?
Yes. :has() is Baseline Widely Available since 2024. Chrome, Firefox, Safari, and Edge all have full support. Global coverage is above 95% in 2026. The only browsers without support are very old Firefox versions (pre-121) that are effectively out of use. For any modern project, skip the fallback.
Can I put a :has() inside another :has()?
No. The Selectors Level 4 specification explicitly forbids it: :has() is not valid inside the argument of another :has(). This includes indirect nesting, so wrapping the inner one in :is() or :not() does not get around the rule.
The consequence is worse than the selector simply not matching. An invalid selector makes the browser drop the entire rule, so all the declarations inside it disappear with no warning in the console.
/* Invalid: the whole rule is discarded */
.card:has(.media:has(video)) { border: 2px solid red; }
/* Valid: one :has() with a descendant selector expresses the same condition */
.card:has(.media video) { border: 2px solid red; }
/* Valid: several :has() in one selector, none inside another's parentheses */
.layout:has(> aside) .card:has(img) { padding: 0; }Almost every nested case collapses into a single :has() with a longer descendant or child selector inside it. Chaining separate :has() calls across different compounds of the same selector is fine and stays readable.
Can :has() style an element based on the sibling that comes after it?
Yes, and this is the closest thing CSS has to a previous sibling combinator. Put + (next sibling) or ~ (any later sibling) inside the :has() argument, and the condition reads forward while the element you style stays on the left.
/* Mark a label whose input is required */
label:has(+ input:required)::after {
content: " *";
color: #dc2626;
}
/* Tighten spacing on a heading that sits directly above a code block */
h3:has(+ pre) { margin-bottom: 0.25rem; }
/* Any paragraph followed later by a figure in the same parent */
article p:has(~ figure) { margin-bottom: 0.5rem; }The subject is still the compound before :has(): the browser finds every label, then checks whether the sibling condition holds. Prefer + over ~ when the structure allows it. A bare ~ asks the engine to scan every following sibling, and a rule like p:has(~ figure) written without a container prefix runs that scan on every paragraph in the document.
:has() changes what CSS can do: not by adding new visual effects, but by giving stylesheets the ability to respond to context that previously required JavaScript.
The patterns above are a starting point. Once you start reaching for :has() in daily work, you'll find new uses in almost every complex UI component you touch.
Related Articles
ARIA in React: Stop Using aria-label Wrong
Pages using ARIA average 41% more accessibility errors. Learn the correct ARIA patterns for React: icon buttons, modals, toasts, spinners, and tab panels.
npm Scripts You're Probably Not Using (But Should Be)
pre/post hooks, cross-env, npm-run-all, argument passing, and built-in variables: the npm script patterns developers Google one at a time, in one place.