Home / Articles / Modern CSS /
:has() from scratch — the parent selector CSS always needed
For decades, CSS could only style downward. :has() flips that — select any element based on what it contains, and build interactive UI without a line of JavaScript.
What :has() actually does
The :has() pseudo-class selects an element if it contains at least one descendant matching the argument selector. Think of it as "select the parent when the child matches." Before :has(), the only way to style a parent based on its children was JavaScript.
/* Select any .card that contains an image */
.card:has(img) {
grid-template-rows: auto 1fr;
}
/* Select any .card that does NOT contain an image */
.card:not(:has(img)) {
padding: 2rem;
}
The first rule applies only when a .card has an <img> descendant. The second uses :not(:has()) to target cards without images. Both were impossible in pure CSS before.
Basic syntax and matching
:has() takes a relative selector list as its argument. The selectors inside are evaluated relative to the element :has() is attached to, not relative to the document root.
/* Direct child only — use > */
.nav:has(> .dropdown) {
position: relative;
}
/* Any descendant (default) */
.form:has(input:focus) {
border-color: oklch(0.72 0.19 265);
}
/* Multiple conditions — any match wins */
.card:has(video, audio) {
background: oklch(0.19 0.02 260);
}
Using the child combinator (>) inside :has() restricts the match to direct children. Without it, any descendant at any depth will match.
Combining :has() with pseudo-classes
The real power of :has() shows up when you combine it with state pseudo-classes like :focus, :checked, :hover, and :valid. This lets you react to user interaction at the parent level.
/* Highlight a form group when its input is focused */
.form-group:has(input:focus) {
background: oklch(0.19 0.03 265);
border-left: 3px solid oklch(0.72 0.19 265);
padding-left: 1rem;
}
/* Style a label when its sibling checkbox is checked */
.option:has(input[type="checkbox"]:checked) label {
color: oklch(0.72 0.19 265);
font-weight: 600;
}
/* Dim the entire form when a required field is invalid */
form:has(:required:invalid:not(:placeholder-shown)) {
opacity: 0.85;
}
Each of these patterns previously required an event listener and a class toggle. With :has(), the CSS engine handles it natively.
Selecting beyond parent: document-level :has()
You are not limited to component-level selectors. :has() on body or html lets you change the entire page based on one element's state — like a global CSS variable toggle.
/* Blur the page when a dialog is open */
body:has(dialog[open]) main {
filter: blur(4px);
pointer-events: none;
}
/* Switch layout when a sidebar toggle is checked */
body:has(#sidebar-toggle:checked) .layout {
grid-template-columns: 1fr;
}
/* Dark mode toggle via checkbox */
html:has(#dark-mode:checked) {
--bg: oklch(0.13 0.02 260);
--text: oklch(0.93 0.01 260);
}
Use document-level :has() sparingly. Browsers have optimized it well, but a broad selector like body:has(*) forces the engine to check every element in the tree.
Negation and compound conditions
Combine :has() with :not(), :is(), and :where() for precise compound conditions that replace multi-step JavaScript logic.
/* Card with an image but no heading */
.card:has(img):not(:has(h2, h3)) {
aspect-ratio: 1;
display: grid;
place-items: center;
}
/* Fieldset valid only when ALL required inputs are valid */
fieldset:not(:has(:required:invalid)) .status-icon::before {
content: "\2713";
color: oklch(0.7 0.18 145);
}
/* Container with either a video or an iframe */
.media:has(:is(video, iframe)) {
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 0.75rem;
}
The :not(:has()) pattern is especially useful — it lets you provide fallback styles for when content is absent, replacing the need for "empty state" class names.
Interactive example: accordion without JS
Here is a complete accordion built with :has() and the native <details> element. No JavaScript, no ARIA toggling — the browser handles accessibility.
.accordion-group:has(details[open]) details:not([open]) {
opacity: 0.5;
}
details[open] summary {
color: oklch(0.72 0.19 265);
border-bottom: 2px solid oklch(0.72 0.19 265);
}
.accordion-group:has(details[open]) {
gap: 0.25rem;
}
.accordion-group:not(:has(details[open])) {
gap: 0.75rem;
}
When one panel opens, all other panels fade to 50% opacity, drawing focus to the active content. The gap adjusts automatically. This entire interaction is declarative.
Browser support and fallbacks
:has() shipped in Chrome 105, Safari 15.4, and Firefox 121. As of 2026, global support exceeds 95%. For the rare older browser, wrap :has() rules in @supports selector(:has(*)) and provide a baseline fallback.
/* Fallback: always show the error hint */
.field .error {
display: block;
}
/* Enhancement: hide it until the input is invalid */
@supports selector(:has(*)) {
.field .error {
display: none;
}
.field:has(input:invalid:not(:placeholder-shown)) .error {
display: block;
color: oklch(0.65 0.2 25);
}
}
This progressive enhancement approach means users on older browsers still get a functional — if less polished — experience.