The :has() Selector: Finally, CSS Parent Selectors
After decades of asking for it, CSS finally has the :has() selector. Learn how to style parents based on their children - the most requested CSS feature of all time.
The :has() Selector: Finally, CSS Parent Selectors
For decades, CSS developers asked the same question: "Can I style a parent element based on its children?"
The answer was always no. You needed JavaScript.
Now, the answer is yes. And it's more powerful than anyone expected.
The Problem We've Had Forever
Imagine a form with optional sections. You want to highlight the entire section if it contains an error:
<fieldset>
<label>Email</label>
<input type="email" />
<!-- if there's an error inside, highlight the whole fieldset -->
</fieldset>
Before :has(), you had two choices:
- Add a class to the fieldset with JavaScript
- Live with the limitation
// The old way: JavaScript was mandatory
document.querySelectorAll("fieldset").forEach((fieldset) => {
if (fieldset.querySelector(".error")) {
fieldset.classList.add("has-error");
}
});
Not terrible, but it's extra logic, extra bundle size, and extra things that can break.
Enter :has()
The :has() selector lets CSS itself check if an element contains something:
/* Style fieldset if it contains an input with aria-invalid */
fieldset:has(input[aria-invalid="true"]) {
border-color: var(--brand-red);
background-color: rgba(237, 32, 61, 0.05);
}
That's it. No JavaScript. No extra classes. Pure CSS.
When an input inside the fieldset has aria-invalid="true", the entire fieldset immediately gets styled. Remove the attribute, and the styles vanish.
What :has() Actually Does
:has() is a relational selector. It matches elements that have certain descendants:
/* "article that contains an image" */
article:has(img) {
display: grid;
}
/* "button that contains a span" */
button:has(span) {
padding: 0.5rem;
}
/* "div that contains a link with class 'active'" */
div:has(a.active) {
background: var(--surface);
}
You can match specific descendants, not just "anything inside."
Complex Selectors with :has()
The selector inside the parentheses can be complex:
/* Parent that contains an input that is invalid */
form:has(input:invalid) {
border: 2px solid var(--brand-red);
}
/* Container that has a hovered child */
.card:has(a:hover) {
shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}
/* List item that has a checked checkbox */
li:has(input:checked) {
background: var(--surface-alt);
font-weight: 500;
}
/* Article that has image but not video */
article:has(img):not(:has(video)) {
columns: 2;
}
Multiple Conditions with :has()
You can chain :has() for multiple conditions:
/* Fieldset with both an error and required field */
fieldset:has(.error):has([required]) {
border-color: var(--brand-red);
}
/* Container with image OR video */
.media-section:has(img, video) {
padding: 2rem;
}
Sibling and Ancestor Queries
:has() isn't limited to direct children. It works with any descendant selector:
/* Container that has a descendant with class 'featured' */
.section:has(.featured) {
border: 2px solid var(--brand-yellow);
}
/* Div that contains any button, nested any depth */
div:has(button) {
display: flex;
}
/* Form that has a disabled input anywhere */
form:has(:disabled) {
opacity: 0.6;
}
Real-World: Form Validation
Here's a practical example: auto-style form groups based on validation state.
/* Group containing input with error */
.form-group:has(input[aria-invalid="true"]) {
--field-color: var(--brand-red);
}
.form-group:has(input:valid) {
--field-color: var(--brand-green);
}
.form-group input {
border-color: var(--field-color, var(--foreground/20));
}
.form-group label {
color: var(--field-color, var(--foreground));
}
.form-group .error-text {
color: var(--field-color);
display: none;
}
.form-group:has(input[aria-invalid="true"]) .error-text {
display: block;
}
No JavaScript. The CSS adapts the entire form group based on input state.
Advanced: Hover on Parent When Child is Hovered
A classic UI pattern: highlight the whole card when you hover the link inside:
.card {
border: 2px solid var(--foreground/5);
transition: all 200ms ease;
}
.card:has(a:hover) {
border-color: var(--brand-yellow);
box-shadow: 0 4px 12px rgba(252, 186, 40, 0.1);
}
.card:has(a:focus-visible) {
outline: 2px solid var(--brand-yellow);
outline-offset: 2px;
}
Hover the link, the card responds. No JavaScript event listeners.
Browser Support
:has() has solid support in modern browsers:
- Chrome 105+ (2022)
- Safari 15.4+ (2022)
- Firefox 121+ (2024)
- Edge 105+ (2022)
For older browsers, the selector simply doesn't match, and you fall back to your base styles. It's progressive enhancement:
.card {
border: 1px solid var(--foreground/10);
}
/* Modern browsers: enhance on validation */
.form:has(input:invalid) .card {
border-color: var(--brand-red);
}
/* Older browsers still have the base style */
When NOT to Use :has()
There are limits:
- Don't use :has() for performance-sensitive animations. It can trigger reflow/repaint as the DOM changes.
- Avoid deep nesting of :has() selectors. They can get expensive to compute.
- Don't use it to replace JS for complex logic. If you need to count children or do calculations, JS is still the right tool.
The Game-Changer
:has() is the selector that web developers asked for most. For decades. And it's even more useful than we imagined because it works with hover states, focus states, validity states - anything you can put in a selector.
This is one of those CSS features that feels small until you start using it. Then you realize how much JavaScript you can delete.
It's validation without the JavaScript. It's hover effects on parents without event listeners. It's CSS doing what CSS should do: describing relationships and applying styles based on the structure of the document.
Finally.