CSS :has() — The Parent Selector That Changes Everything
CSS :has() — The Parent Selector That Changes Everything
For decades, CSS could only style elements based on their ancestors or preceding siblings — never their descendants. :has() is the relational pseudo-class that inverts this: it matches an element based on what it contains.
Basic Syntax
/* Select a figure that contains an img */
figure:has(img) {
border: 1px solid oklch(0.8 0.05 260);
padding: 1rem;
}
/* Select a label that contains a required input */
label:has(input[required]) {
font-weight: 600;
}
/* Select a section that has no children */
section:not(:has(*)) {
display: none;
}Conditional Card Layouts
Style a card differently depending on whether it has an image:
.card {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Card with an image gets a two-column layout */
.card:has(.card-image) {
flex-direction: row;
}
.card:has(.card-image) .card-image {
width: 200px;
flex-shrink: 0;
}No JavaScript, no additional classes, no conditional rendering tricks.
Form State Styling
:has() makes form state styling significantly cleaner:
/* Form group is valid when its input has a value and no error */
.form-group:has(input:valid:not(:placeholder-shown)) label {
color: oklch(0.55 0.18 145); /* Green */
}
/* Form group is invalid */
.form-group:has(input:invalid:not(:placeholder-shown)) label {
color: oklch(0.55 0.22 25); /* Red */
}
/* Disable submit when any required field is empty */
form:has(input:required:invalid) button[type="submit"] {
opacity: 0.5;
pointer-events: none;
}Sibling Selection via Parent
:has() enables a form of "sibling-before" selection that CSS never had:
/* Style an element that comes BEFORE a certain sibling */
/* "Select any li that has a following sibling li.active" */
li:has(~ li.active) {
opacity: 0.6;
}This was previously impossible without JavaScript.
Quantity Queries
Count children and style accordingly:
/* Grid with 1 item — full width */
.grid:has(:nth-child(1):last-child) {
grid-template-columns: 1fr;
}
/* Grid with 2 items — two columns */
.grid:has(:nth-child(2):last-child) {
grid-template-columns: 1fr 1fr;
}
/* Grid with 3+ items — three columns */
.grid:has(:nth-child(3)) {
grid-template-columns: repeat(3, 1fr);
}Navigation Highlighting
/* Style the nav differently when a dropdown is open */
nav:has(.dropdown[open]) {
background-color: oklch(0.1 0 0 / 0.9);
}
/* Overlay when modal is open — no JavaScript class toggling */
body:has(dialog[open]) {
overflow: hidden;
}
body:has(dialog[open])::before {
content: "";
position: fixed;
inset: 0;
background: oklch(0 0 0 / 0.5);
}The body:has(dialog[open]) pattern is a clean replacement for the common document.body.classList.add("modal-open") pattern.
Performance Considerations
:has() with complex selectors can be expensive. Guidelines:
- Avoid
:has(*)on large lists (checks every child). - Prefer
:has(> child)(direct child) over:has(descendant)when possible. - Avoid using
:has()on the:rootorhtmlelement with complex selectors — it recalculates on every DOM change.
Browser Support
:has() is fully supported in all modern browsers:
- Chrome 105+ (August 2022)
- Safari 15.4+ (March 2022 — shipped first!)
- Firefox 121+ (December 2023)
- Edge 105+ (August 2022)
:has() is already changing how CSS is written. Start replacing JavaScript-driven class toggling with :has() — the result is leaner, more declarative, and more maintainable.