Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /8 CSS :has() Patterns You'll Actually Use (2026)
html css15 min read

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.

Zeeshan Tofiq
Zeeshan Tofiq
May 30, 2026
On this page

On this page

  • Form Group Validation State
  • Active Nav Item
  • Content-Aware Card Layout
  • Modal Scroll Lock
  • Sibling Dimming on Hover
  • Checked Checkbox Label
  • Quantity-Based Grid Layout
  • Input Clear Button Visibility
  • Pattern Quick Reference
  • Performance Considerations
  • Frequently Asked Questions

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.

ℹ Browser support

:has() is Baseline Widely Available since 2024. Chrome, Firefox, Safari, Edge: all fully supported. Global coverage above 95% in 2026. Use it without fallbacks.

  1. 1

    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.

    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;
    }

    💡 Tip

    Combine with :has(input:user-invalid) to only show the red state after the user has interacted with the field, not on first page load.

  2. 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 — CSS
    nav li:has(a.active) {
      background-color: #ebf8ff;
      border-radius: 6px;
    }
    
    nav li:has(a.active) a {
      font-weight: 600;
      color: #2b6cb0;
    }

    💡 Tip

    With Next.js, <Link> automatically receives aria-current="page" on the active route. Use nav li:has(a[aria-current='page']) instead of a class: no extra code needed.

  3. 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;
    }

    ℹ Info

    :not(:has(img)) selects cards without images. The card decides its own layout based on what is actually inside it: no class-toggling logic needed.

  4. 4

    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.

    html — HTML structure
    <dialog id="my-modal">
      <p>Modal content</p>
      <button onclick="document.getElementById('my-modal').close()">Close</button>
    </dialog>
    css — CSS
    body:has(dialog[open]) {
      overflow: hidden;
    }

    💡 Tip

    Also works with custom modals: body:has(.modal.is-open). The native <dialog> element has its open attribute managed by the browser when you call dialog.showModal() and dialog.close().

  5. 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;
    }

    ℹ Info

    Read it aloud: "when the grid has a hovered card, select every card that is not currently hovered". The parent container becomes context-aware through its children's state, with no JavaScript.

  6. 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 input
    label: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 label
    input[type="checkbox"]:checked + label {
      color: #2b6cb0;
    }

    ℹ Info

    Both patterns work for radio buttons, toggle switches, and any <input> with a :checked state. Use the :has() version when the input is inside the label; use + when they are siblings.

  7. 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;
    }

    💡 Tip

    Extend the pattern for any count: :has(:nth-child(4)) checks for at least 4 items, :has(:nth-child(n+5)) for 5 or more. Combine with @container queries for truly responsive component logic.

  8. 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-shown is 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;
    }

    ℹ Info

    :placeholder-shown is true when the placeholder is visible (field empty). :not(:placeholder-shown) targets inputs that have content. Supported in all major browsers.

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.

Validation pseudo-classes worth pairing with :has()
Pseudo-classMatches whenUse it for
:invalidValue fails constraint validation, including on first paintForm-level checks like disabling a submit affordance
:user-invalidFails validation and the user has already interactedPer-field red borders and inline error text
:validValue passes constraint validationRarely useful alone: matches empty optional fields too
:user-validPasses validation after user interactionGreen checkmarks that only appear once earned
:requiredControl has the required attributeRendering an asterisk on the label with no extra markup
:placeholder-shownInput is empty and its placeholder is visibleFloating labels and conditional clear buttons
:checkedCheckbox, radio, or option is selectedSelection cards, toggles, bulk-action bars
:indeterminateCheckbox set indeterminate in JS, or radio group untouchedTri-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.

css — Form-level reactions
/* 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;
}

⚠ Do not confuse styling with disabling

Fading a submit button is a visual hint, not a lock. Leave the button enabled so keyboard and screen reader users can press it and receive the browser's own validation messages, which announce what is wrong and move focus to the offending field. A truly disabled submit button gives assistive technology users no feedback at all.

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.

css — Conditional fields and selection cards
/* 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.

css — The pre-:has() quantity query
/* 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.

css — Quantity queries with :has()
/* 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;
}
Quantity query recipes
You wantSelectorReads 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

💡 Tip

Chaining two :has() pseudo-classes on the same element (.list:has(a):has(b)) is completely legal and is how you express AND. Nesting one inside the other (.list:has(a:has(b))) is not legal and kills the whole rule. More on that in the gotchas section.

The same mechanism answers a different question: not how many children, but what kind. This is where content-driven components stop needing variant props.

css — Child-type queries
/* 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.

How :has() and :not() affect specificity
SelectorSpecificityWhy
.card:has(img)0,1,1one class plus one element
.card:has(.badge)0,2,0two classes
.card:not(:has(img))0,1,1:not() inherits the weight of :has(img)
li:has(a[aria-current="page"])0,1,2element, element, attribute selector
.card:has(.badge, #promo)1,1,0the 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.

css — Keeping :has() specificity flat
/* 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.

css — Double negatives: powerful, hard to read
/* 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;
}

ℹ Info

Read :not(:has(:not(x))) from the inside out: "contains something that is not x", then "has something that is not x", then "does not". The result is "everything inside is x". If a teammate has to do that out loud, add a comment above the rule.

Pattern Quick Reference

ProblemSelector patternTailwind v4 equivalent
Style form row on invalid input.form-group:has(input:invalid)has-[input:invalid]:border-red-500
Active nav item wrapperli: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 openbody: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 labellabel: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)) .btnN/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:

html
<!-- 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.

css
/* 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.

css
/* 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.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

react

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.

Jun 1, 2026·14 min read
javascript

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.

Jun 1, 2026·26 min read

On this page

  • Form Group Validation State
  • Active Nav Item
  • Content-Aware Card Layout
  • Modal Scroll Lock
  • Sibling Dimming on Hover
  • Checked Checkbox Label
  • Quantity-Based Grid Layout
  • Input Clear Button Visibility
  • Pattern Quick Reference
  • Performance Considerations
  • Frequently Asked Questions