CSS :has() — The Parent That Knows What's Happening Inside
CSS :has() — The Parent That Knows What's Happening Inside
You have a form field: a <div class="field"> with a label and an input inside. The requirement sounds trivial — when the input is invalid, the whole container should get a red border and a warning icon next to the label, not just the input itself. You reach for .field input:invalid — and hit a wall. That selector would style the input, if it wanted a red border. But you want to style .field, the parent of that input, based on what's happening inside it. And it turns out that CSS — despite dozens of combinators, pseudo-classes, and attribute selectors — never, in its 25-year history, gave you a way to do that.
This isn't a gap in your CSS knowledge. It's a fundamental feature of selector architecture that held from CSS1 all the way to 2022.
Why a combinator always looks in only one direction
Every combinator in CSS — the space (descendant), > (direct child), + (adjacent sibling), ~ (general sibling) — describes a relationship between two selectors, but the element styled is always the one on the right side of the combinator. .field input styles the input inside .field. .field ~ .error styles the .error that's a sibling of .field. The direction is always the same: from context to target, never the other way around. When the CSS engine encounters .field, it has no built-in mechanism to "look inside" and change its decision about styling .field itself based on what it finds there.
For years, developers worked around this in two ways, both with real costs. The first: JavaScript listening for an input/blur event, manually adding a .field--invalid class to the parent. The second, cleverer but fragile — the so-called checkbox/radio hack, using ~ to style a sibling based on :checked state, which only works when the elements are actually siblings in a flat DOM structure, not nested the way a real form requires.
jsx
// The pre-:has() workaround – works, but needs JS for something// that's a purely visual consequence of HTML stateimport { useState } from "react";const FormField = ({ label, ...inputProps }) => {const [isInvalid, setIsInvalid] = useState(false);return (<div className={`field ${isInvalid ? "field--invalid" : ""}`}><label>{label}</label><input{...inputProps}onBlur={(e) => setIsInvalid(!e.target.validity.valid)}/></div>);};
:has() eliminates this code not because it's a cleverer trick — but because it solves the problem at its source, as the first relational pseudo-class in CSS history that lets an element query its own interior.
How :has() really works: a selector anchored to the element, not the descendant
The key shift in thinking: .field:has(input:invalid) doesn't style the input. It styles .field — precisely the element the pseudo-class is attached to — provided that somewhere inside it there's an element matching the selector inside the parentheses. The element you "anchor" on always stays the same; :has() just decides whether it gets a match at all.
css
.field:has(input:invalid) {border-color: #c62828;}.field:has(input:invalid) .field__icon {visibility: visible;}
By default, the selector inside :has() looks for any descendant at any depth — just like a plain space. But you can narrow the relationship exactly the same way you would in regular CSS, using combinators right inside the parentheses:
css
/* Only a direct child, not any descendant */.card:has(> img) {grid-template-columns: 120px 1fr;}/* An element immediately followed by .field-hint as a sibling */.field:has(+ .field-hint) {margin-bottom: 4px;}
That makes :has() not just one new trick, but a generalization — it lets you express in CSS any relationship you could previously describe with a combinator, just pointed "inward" or "backward," instead of only "forward."
A practical example: a form field that knows on its own that it's invalid
Let's put this together into a complete, accessible component. One important detail: :invalid on its own fires immediately when an empty field with a required attribute loads — before the user has typed anything, which would give you a red border on an empty, untouched form. :not(:placeholder-shown) solves this by only matching a field once the user has actually left something in it.
jsx
const FormField = ({ label, id, ...inputProps }) => (<div className="field"><label htmlFor={id}>{label}</label><input id={id} {...inputProps} /><svg className="field__icon" aria-hidden="true" viewBox="0 0 20 20"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 12H9v-2h2v2zm0-4H9V5h2v5z" /></svg></div>);export default FormField;
css
.field {position: relative;border: 1px solid #ccc;border-radius: 8px;padding: 8px 12px;transition: border-color 0.15s ease;}.field__icon {position: absolute;right: 12px;top: 50%;transform: translateY(-50%);width: 18px;fill: #c62828;visibility: hidden;}/* The field is "touched" (something was typed) AND invalid – only then react */.field:has(input:not(:placeholder-shown):invalid) {border-color: #c62828;background: #fff5f5;}.field:has(input:not(:placeholder-shown):invalid) .field__icon {visibility: visible;}/* Positive confirmation matters just as much as an error */.field:has(input:not(:placeholder-shown):valid) {border-color: #2e7d32;}
Not a single line of JavaScript is responsible for the look. The validation state already exists natively in the browser — :valid/:invalid have been there since CSS3 — :has() simply finally lets you move that existing state from the input to its container, right where it was visually needed all along.
A second pattern: a component that knows what it contains
The same mechanism solves a completely different class of problems — layout that depends on the presence of specific content, with no prop telling it about that ahead of time.
css
/* A card with an image gets a two-column layout, a card without one gets full-width text */.card:has(> img) {display: grid;grid-template-columns: 96px 1fr;gap: 12px;}/* A section heading gets bottom spacing only if the section actually has a subtitle */.section:has(> .section__subtitle) .section__title {margin-bottom: 4px;}/* A list with no items shows an empty state instead of empty space */.product-list:not(:has(li)) {display: block;}.product-list:not(:has(li))::after {content: "No products match these criteria.";color: #666;}
Without :has(), each of these cases would require either a prop (hasImage, isEmpty) set manually by the parent component, or checking array length in JSX and conditionally rendering a separate class. :has() lets a component recognize its own content by itself and react to it, instead of being told about it from the outside — exactly the same direction of thinking as the product card that's aware of its own container from the Container Queries article: less state passed around manually, more logic that follows directly from the structure.
Pitfalls and best practices
- The specificity of
:has()is the specificity of the most specific selector inside it, not zero..field:has(input:invalid)has the combined specificity of two classes and a pseudo-class — don't count on:has()"not counting" toward specificity, because in practice it regularly beats simpler rules written later in the sheet. - A deeply nested, broad
:has()with no combinator can genuinely cost you..app:has(.some-deeply-nested-element)theoretically forces the engine to consider the entire.appsubtree on every DOM change inside it. Modern engines (Chromium, WebKit) optimize this through so-called invalidation sets — they don't blindly recompute everything — but the effect still depends on the specific selector and the size of the tree. Narrow the relationship with a combinator (>, direct child) wherever you can, instead of the default deep search. :has()doesn't replace:focus-within. If all you need is "the parent reacts when a descendant has focus,":focus-withinhas been around for a while, is cheaper to compute, and reads more clearly —:has(:focus)gives you practically the same effect, but there's no reason to reach for the more general tool where a specialized one already exists.- Browser support has stopped being a problem.
:has()landed as the last big piece of the puzzle — Safari had it since 15.4 (March 2022), Chrome since 105 (August 2022), Firefox joined last, in version 121 (December 2023). Since then it's been safe to use without@supportsin any new project.
Conclusion
For 25 years, CSS could only describe relationships pointed in one direction — from context to target, from parent to child, from predecessor to successor. :has() isn't another variant of the same idea, it's the first pseudo-class that lets an element query its own interior and react to what it finds there — a validation error in a field, the presence of an image in a card, the absence of items in a list. It moves an entire class of decisions that previously had to land in JavaScript or in props passed down manually, back where they belong: into the HTML structure and the CSS rules that describe it.