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
Introduction
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 copy-paste CSS and the real-world problem it solves.
Step-by-Step Guide
Style a Form Group When Its Input Is Invalid
The classic problem: you want the label, border, and error state on a .form-group to turn red when the <input> inside it fails validation. Before :has(), this required JavaScript to add a class to the parent element.
.form-group:has(input:invalid) {
border-color: #e53e3e;
background-color: #fff5f5;
}
.form-group:has(input:invalid) label {
color: #e53e3e;
}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> — which is awkward with React Router and Next.js <Link>.
nav li:has(a.active) {
background-color: #ebf8ff;
border-radius: 6px;
}
nav li:has(a.active) a {
font-weight: 600;
color: #2b6cb0;
}Cards That Adapt Layout to Their Own Content
Cards with images need a different layout than text-only cards. Previously this meant JavaScript at render time or separate component variants with different class names.
.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;
}Lock Body Scroll When a Modal Is Open
One line. No JavaScript. The moment a <dialog> element with the open attribute exists anywhere in the page, scrolling stops. When the dialog closes, scrolling returns automatically.
body:has(dialog[open]) {
overflow: hidden;
}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.
.grid:has(.card:hover) .card:not(:hover) {
opacity: 0.5;
transform: scale(0.98);
transition: opacity 150ms ease, transform 150ms ease;
}Style a Label When Its Checkbox Is Checked
Custom checkbox styling without JavaScript. The label containing or adjacent to a checked checkbox styles itself directly:
- The
:has()version works when the<input>is nested inside the<label>element. - The
+adjacent sibling version works when<input>and<label>are siblings — no:has()needed for that specific pattern. - Both work for radio buttons, toggle switches, and any
<input>with a:checkedstate.
label:has(input[type="checkbox"]:checked) {
font-weight: 600;
color: #2b6cb0;
text-decoration: line-through;
}
/* For adjacent label pattern */
input[type="checkbox"]:checked + label {
color: #2b6cb0;
}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.
/* 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;
}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-shown is false when the input has a value — no JavaScript needed.
.input-wrapper .clear-btn {
display: none;
}
.input-wrapper:has(input:not(:placeholder-shown)) .clear-btn {
display: flex;
align-items: center;
}When Not to Use :has()
:has() is production-ready, but a few patterns create unnecessary style recalculations. The rule: keep selectors scoped.
- 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, so you can writehas-[input:invalid]:border-red-500directly in your class list. If you need more complex :has() patterns, write them in a custom CSS file or inside a @layer — Tailwind and custom CSS 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 — the specificity comes from the selector inside it. So
.card:has(img)has the same specificity as.card img(one class + one tag = 0,1,1). :has() with a class inside —.card:has(.featured)— has specificity 0,2,0 (two classes). This is lower than you might expect, which makes it easy to override. - Is :has() safe to use without a fallback?
- Yes. :has() is a Baseline Widely Available feature as of 2024. All major browsers — Chrome, Firefox, Safari, Edge — have full support. Global coverage is above 95% in 2026. The only browsers without support are very old versions of Firefox (pre-121) that are effectively out of use. For any modern project, use it without fallbacks.
: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
How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)
Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.
5 async/await Mistakes That Slow Your JavaScript Code
Sequential awaits, await in forEach, missing Promise.all — these 5 async/await mistakes silently slow your JavaScript. Here's how to spot and fix each one.