CSS :has() Selector
The :has()pseudo-class selects a parent element based on what its children contain. Often called the "parent selector," it fills a gap that CSS developers have wanted for over a decade — the ability to style a container based on its descendants, siblings, or content state.
Before :has(), you had to rely on JavaScript to detect child states and toggle classes on parent elements. Now, the browser handles it natively with zero scripting. The selector is incredibly versatile: detect empty containers, style forms based on input state, create conditional layouts, and much more.
| 1 | /* Style a card differently when it contains an image */ |
| 2 | .card:has(img) { |
| 3 | display: grid; |
| 4 | grid-template-columns: 200px 1fr; |
| 5 | } |
| 6 | |
| 7 | /* Style a list item when it has a checked checkbox */ |
| 8 | .todo-item:has(input:checked) { |
| 9 | opacity: 0.5; |
| 10 | text-decoration: line-through; |
| 11 | } |
| 12 | |
| 13 | /* Style a form group when it contains an invalid input */ |
| 14 | .form-group:has(input:invalid) { |
| 15 | border-color: #ef4444; |
| 16 | } |
The :has() function accepts a selector list — the same format you would pass to :is() or :where(). You can combine multiple selectors with commas inside the function.
| 1 | /* Single selector */ |
| 2 | .parent:has(.child) { /* styles */ } |
| 3 | |
| 4 | /* Multiple selectors (OR logic) */ |
| 5 | .card:has(img, video, svg) { /* styles */ } |
| 6 | |
| 7 | /* Compound selectors */ |
| 8 | .sidebar:has(> .widget.active) { /* styles */ } |
| 9 | |
| 10 | /* Negation inside :has() */ |
| 11 | .form:has(input:not(:placeholder-shown)) { /* styles */ } |
| 12 | |
| 13 | /* :has() combined with other pseudo-classes */ |
| 14 | .article:has(h2):hover { /* styles */ } |
The argument inside :has() is relative to the element the pseudo-class is applied to. The : at the start of a compound selector is optional — both :has(.active) and :has(.active) work identically.
note
:has() shines when combined with other selectors. You can query for deeply nested descendants, sibling relationships, and compound conditions that would otherwise require multiple JavaScript event listeners.
| 1 | /* Descendant of a descendant */ |
| 2 | .page:has(.sidebar .ad-banner) { |
| 3 | /* Apply layout shift when sidebar has an ad */ |
| 4 | --sidebar-width: 320px; |
| 5 | } |
| 6 | |
| 7 | /* Adjacent sibling inside a parent */ |
| 8 | .list:has(li + li) { |
| 9 | /* Style a list only when it has more than one item */ |
| 10 | padding: 0; |
| 11 | } |
| 12 | |
| 13 | /* :has() with combinators */ |
| 14 | .nav-item:has(+ .nav-item.active) { |
| 15 | /* Style the item BEFORE the active item */ |
| 16 | color: #808080; |
| 17 | } |
| 18 | |
| 19 | /* Nested :has() — parent contains an element that has a child */ |
| 20 | .form:has(.field:has(input:focus)) { |
| 21 | /* Form that currently has a focused field */ |
| 22 | box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5); |
| 23 | } |
info
One of the most practical uses of :has() is detecting empty containers and showing placeholder content. Combined with :not(:has()), you can create elegant empty states without JavaScript DOM checks.
| 1 | /* Style a container when it has NO children */ |
| 2 | .empty-state:empty { |
| 3 | min-height: 200px; |
| 4 | display: flex; |
| 5 | align-items: center; |
| 6 | justify-content: center; |
| 7 | border: 2px dashed #333; |
| 8 | color: #808080; |
| 9 | font-size: 14px; |
| 10 | } |
| 11 | |
| 12 | /* Alternative using :not(:has()) */ |
| 13 | .list-container:not(:has(li)) { |
| 14 | min-height: 120px; |
| 15 | background: #1A1A2E; |
| 16 | border-radius: 8px; |
| 17 | display: grid; |
| 18 | place-items: center; |
| 19 | } |
| 20 | |
| 21 | /* Show a placeholder when a section has no images */ |
| 22 | .gallery:not(:has(img))::before { |
| 23 | content: "No images yet"; |
| 24 | color: #808080; |
| 25 | font-style: italic; |
| 26 | } |
Cards often have optional elements — an image, a badge, a footer. With :has(), you can conditionally adjust the layout based on which elements are present, keeping your HTML clean and your CSS adaptive.
| 1 | .card { |
| 2 | padding: 24px; |
| 3 | background: #1A1A2E; |
| 4 | border-radius: 12px; |
| 5 | border: 1px solid #222; |
| 6 | } |
| 7 | |
| 8 | /* Horizontal layout when card has an image */ |
| 9 | .card:has(img) { |
| 10 | display: grid; |
| 11 | grid-template-columns: 180px 1fr; |
| 12 | gap: 20px; |
| 13 | } |
| 14 | |
| 15 | /* Add top padding when there is a badge */ |
| 16 | .card:has(.badge) { |
| 17 | padding-top: 40px; |
| 18 | position: relative; |
| 19 | } |
| 20 | |
| 21 | /* Add bottom border when footer is present */ |
| 22 | .card:has(footer) { |
| 23 | border-bottom: 3px solid #00FF41; |
| 24 | } |
| 25 | |
| 26 | /* Compact card when it has both image and badge */ |
| 27 | .card:has(img):has(.badge) { |
| 28 | grid-template-columns: 140px 1fr; |
| 29 | padding: 16px; |
| 30 | } |
| 1 | <!-- Card with image — automatic horizontal layout --> |
| 2 | <div class="card"> |
| 3 | <img src="photo.jpg" alt="Photo"> |
| 4 | <div> |
| 5 | <h3>Card Title</h3> |
| 6 | <p>Description text</p> |
| 7 | </div> |
| 8 | </div> |
| 9 | |
| 10 | <!-- Card without image — default vertical layout --> |
| 11 | <div class="card"> |
| 12 | <h3>Text Only Card</h3> |
| 13 | <p>No image, no grid — just stacked content</p> |
| 14 | </div> |
| 15 | |
| 16 | <!-- Card with badge — shifted content down --> |
| 17 | <div class="card"> |
| 18 | <span class="badge">New</span> |
| 19 | <h3>Featured Card</h3> |
| 20 | <p>Badge pushes content down with extra padding</p> |
| 21 | </div> |
:has() transforms form styling. Instead of relying on adjacent sibling selectors (which only work when the error message immediately follows the input), you can now style the entire form group based on its input state.
| 1 | .form-group { |
| 2 | padding: 16px; |
| 3 | border: 1px solid #222; |
| 4 | border-radius: 8px; |
| 5 | transition: border-color 0.2s, background 0.2s; |
| 6 | } |
| 7 | |
| 8 | /* Highlight when any input in the group is focused */ |
| 9 | .form-group:has(input:focus) { |
| 10 | border-color: #3B82F6; |
| 11 | background: rgba(59, 130, 246, 0.05); |
| 12 | } |
| 13 | |
| 14 | /* Error state when input is invalid and not empty */ |
| 15 | .form-group:has(input:invalid:not(:placeholder-shown)) { |
| 16 | border-color: #ef4444; |
| 17 | background: rgba(239, 68, 68, 0.05); |
| 18 | } |
| 19 | |
| 20 | /* Success state when input is valid */ |
| 21 | .form-group:has(input:valid:not(:placeholder-shown)) { |
| 22 | border-color: #22c55e; |
| 23 | background: rgba(34, 197, 94, 0.05); |
| 24 | } |
| 25 | |
| 26 | /* Style the submit button when ALL fields are valid */ |
| 27 | form:has(input:valid:not(:placeholder-shown)):has(textarea:valid:not(:placeholder-shown)) button[type="submit"] { |
| 28 | background: #3B82F6; |
| 29 | cursor: pointer; |
| 30 | } |
| 31 | |
| 32 | /* Disable button when any field is invalid */ |
| 33 | form:has(input:invalid:not(:placeholder-shown)) button[type="submit"] { |
| 34 | opacity: 0.5; |
| 35 | cursor: not-allowed; |
| 36 | } |
| 1 | /* Good — simple, readable */ |
| 2 | .card:has(img) { |
| 3 | display: grid; |
| 4 | grid-template-columns: 200px 1fr; |
| 5 | } |
| 6 | |
| 7 | /* Avoid — overly complex, hard to maintain */ |
| 8 | .page:has(> header:has(> nav:has(.active)):has(> .banner)) .main { |
| 9 | /* This is fragile and hard to reason about */ |
| 10 | } |
| 11 | |
| 12 | /* Better — break into smaller, composable rules */ |
| 13 | .page:has(header nav .active) .main { |
| 14 | /* Responsive when nav is expanded */ |
| 15 | } |
| 16 | |
| 17 | .page:has(.banner) .main { |
| 18 | /* Account for banner presence */ |
| 19 | } |
The :has() selector is supported in all major browsers as of 2024. It is production-ready for modern web applications.
| Browser | Support | Version |
|---|---|---|
| Chrome | ✓ | 105+ |
| Edge | ✓ | 105+ |
| Firefox | ✓ | 121+ |
| Safari | ✓ | 15.4+ |