CSS :has() — The Parent Selector That Changes Everything
Conditional styling based on descendants, siblings and form state, with the performance caveats that actually matter.
For a long time CSS could only style an element from its ancestors or from siblings before it. :has() 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;
}section:not(:has(*)) is the one I reach for most. CMS templates render a wrapper whether or not there is content in it.
Conditional Card Layouts
.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;
}I had a grid where cards without images were shorter and the rows never lined up. Tying the layout to the content fixed it better than the modifier class did.
Form State Styling
/* 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;
}Keep the server-side validation. pointer-events: none will not stop someone tabbing to the button and pressing Enter.
Sibling Selection via Parent
/* 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;
}Step indicators are the obvious use: everything before the current step dims. Doing that from JavaScript meant a second pass over the list.
Quantity Queries
/* 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);
}:has(:nth-child(3)) is true for three items and for thirty, so the last rule means "three or more". Exact counts past three get ugly.
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);
}body:has(dialog[open]) replaced a modal-open class in one of my projects. The scroll lock can't drift out of sync with the dialog now, which used to happen when a route change closed it without running cleanup.
Performance Considerations
Engines handle :has() better than they used to, but the selector is still re-evaluated when the DOM changes:
- Avoid
:has(*)on large lists, since it checks every child. - Prefer
:has(> child)over:has(descendant)when the markup allows it. - Keep complex
:has()selectors off:rootandhtml.
Browser Support
- Chrome 105+ (August 2022)
- Safari 15.4+ (March 2022, which was first)
- Firefox 121+ (December 2023)
- Edge 105+ (August 2022)
If your audience includes an older Firefox ESR, treat :has() as an enhancement rather than load-bearing layout.
I have been deleting modal-open-style classes since Safari shipped this. Try it on one conditional layout first; stylesheet logic is easier to leave alone six months later.