CSS :has() — The Parent Selector That Changes Everything

Conditional styling based on descendants, siblings and form state, with the performance caveats that actually matter.

csstutorialintermediate

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 :root and html.

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.