CSS :has() Changed Everything: The Selector That Eliminated JavaScript From My Stylesheets
A deep dive into CSS :has() - the parent selector that finally arrived, and how it fundamentally changes the relationship between CSS and JavaScript in modern web development.
"We Wrote So Much JavaScript Just to Style Things"
Let me paint you a picture. It is 2021, and I am staring at a component that needs to change its container's background colour when a checkbox inside it is checked. A purely visual change. No data to update, no API to call, no state to persist. Just a background colour.
And yet, here I am writing JavaScript for it.
const checkbox = document.querySelector('.feature-toggle');
const container = document.querySelector('.feature-card');
checkbox.addEventListener('change', (e) => {
container.classList.toggle('is-active', e.target.checked);
});This was the norm. Every frontend developer I knew had written some version of this pattern hundreds of times. We had accepted it as an immutable truth of web development: CSS selects downward. If you need to style something based on its children or descendants, you reach for JavaScript.
The patterns were everywhere. Want to highlight a form group when its input has focus? JavaScript. Need to hide a section header when its content area is empty? JavaScript. Want to change a grid layout based on how many items it contains? Absolutely JavaScript, and probably a ResizeObserver thrown in for good measure.
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
const container = mutation.target;
if (container.children.length === 0) {
container.classList.add('is-empty');
} else {
container.classList.remove('is-empty');
}
});
});
observer.observe(listContainer, { childList: true });We were using MutationObservers - a tool designed for complex DOM monitoring - just to toggle CSS classes. We were shipping kilobytes of JavaScript that existed solely to bridge the gap between what CSS could select and what we needed to style. React components carried useEffect hooks that did nothing but synchronise visual state. Vue watchers fired just to add or remove a class name.
We had built an entire ecosystem of workarounds for a single missing CSS capability: the ability to look at your children before deciding how to dress.
The irony was not lost on me. We preached separation of concerns - HTML for structure, CSS for presentation, JavaScript for behaviour - and then violated it constantly because CSS simply could not express the relationships we needed. Every classList.toggle() for a visual-only change was a small admission of defeat. Every event listener attached purely for styling purposes was technical debt wearing a disguise.
The community had been asking for a parent selector since the early 2000s. Browser vendors said it was impossible to implement performantly. The CSS Working Group had considered and rejected proposals multiple times. And so we kept writing JavaScript.
Then, in 2022, everything changed.
"What :has() Actually Is - And Why Calling It a 'Parent Selector' Undersells It"
The :has() pseudo-class is formally described in the CSS Selectors Level 4 specification as a relational pseudo-class. It takes a relative selector list as its argument and matches an element if at least one of those relative selectors matches at least one element when anchored against the original element.
In plain language: it lets any element ask questions about its descendants, its siblings, and even its subsequent structure - and style itself based on the answers.
/* Style a parent based on its children */
.card:has(.card__image) {
grid-template-rows: auto 1fr;
}
/* Style a parent based on child state */
.form-group:has(input:focus) {
border-colour: var(--colour-primary);
}
/* Style a sibling based on another sibling */
h2:has(+ .subtitle) {
margin-bottom: 0.25rem;
}Yes, that last example is the critical bit most people miss. The :has() selector is not just a parent selector. It is a relational selector. It can look at children, at adjacent siblings (using +), at general siblings (using ~), at descendants at any depth. It fundamentally changes what CSS can express.
Think of :has() as a conditional check. Instead of "select elements of type X", you can now say "select elements of type X, but only if they have a relationship Y with element Z". It is an if-statement for your selectors.
Consider the implications. Before :has(), CSS selectors were essentially a one-way street. You could style a child based on its parent (.parent .child), but never the reverse. You could style a sibling that comes after another sibling (.first + .second), but never the one that comes before. The cascade flowed in a single direction.
Now, the selector engine can reason bidirectionally. A parent can respond to its children. A preceding sibling can respond to a following sibling. An ancestor can respond to a deeply nested descendant's state.
/* Previous sibling selector (finally!) */
.item:has(+ .item:hover) {
opacity: 0.7;
transform: scale(0.95);
}
/* Ancestor responding to deep descendant */
body:has(.modal[open]) {
overflow: hidden;
}
/* Element responding to its own pseudo-element state */
details:has(> summary:focus-visible) {
outline: 2px solid var(--colour-focus);
}That body:has(.modal[open]) pattern alone used to require JavaScript in every single modal library I have ever used. Open the modal, add overflow: hidden to the body. Close the modal, remove it. Now it is a single line of CSS that reacts to the DOM automatically. No event listeners. No cleanup. No memory leaks from forgotten event handlers.
:has() did not just give CSS a parent selector. It gave CSS the ability to reason about relationships in every direction. That is a fundamentally different language than what we had before.
The W3C specification was deliberately broad in its design. Rather than creating a narrow :parent pseudo-class (which had been proposed and rejected multiple times), they created a general-purpose relational mechanism. This was the right call. A :parent selector would have solved one problem. The :has() selector dissolved an entire category of problems.
"From 'Experimental' to 'Baseline' in Record Time"
The browser support story for :has() is genuinely remarkable, and it speaks to how desperately the web platform needed this capability. When browser vendors collectively prioritise a feature, things move fast. And they moved very fast here.
Safari actually led the charge here, which surprises people who still think of it as the "new IE". WebKit shipped :has() in Safari 15.4 back in March 2022, months before Chrome. Apple's engineers did significant work on selector performance optimisation to make this viable, and their implementation informed the approaches other browsers took.
Chrome followed in August 2022 with version 105. The Chromium team had initially been cautious about :has(), citing performance concerns that had haunted parent selector proposals for years. But Igalia (the open-source consultancy that contributes heavily to browser engines) and the Chrome team developed clever invalidation strategies that made it practical.
Firefox was the last major holdout, shipping support in December 2023 with version 121. Their implementation took longer partly because Gecko's style system had different architectural assumptions, but the Mozilla team used the extra time to ensure their implementation was both correct and performant from day one.
As of early 2025, :has() is part of the Baseline "Widely Available" set on web platform status trackers. With over 96% global browser support, there is no technical reason to avoid it in production. If you are still writing JavaScript for parent-dependent styling, you are shipping unnecessary code.
The rapid convergence tells us something important: all three major browser engines recognised that the lack of relational selectors was causing real harm to the web platform. Developers were shipping JavaScript to compensate, which meant larger bundles, more potential for bugs, worse accessibility, and a fundamentally worse user experience on low-powered devices.
If you absolutely must support browsers without :has(), the @supports selector(:has(*)) query lets you provide fallbacks cleanly. But in 2025, the question is increasingly not "can I use :has()?" but rather "why am I not using it already?"
"Form Validation States Without a Single Line of JavaScript"
Forms were where I first felt the true power of :has(), because forms were where I had written the most unnecessary JavaScript.
<div class="form-group"> <label for="email">Email Address</label> <input type="email" id="email" required placeholder="you@example.com" /> <span class="form-hint">We will never share your email</span> <span class="form-error">Please enter a valid email address</span> </div>
/* Highlight the entire group when input is focused */
.form-group:has(input:focus) {
border-left: 3px solid var(--colour-primary);
background: var(--colour-primary-subtle);
}
/* Show error state for invalid, interacted-with inputs */
.form-group:has(input:not(:placeholder-shown):invalid) {
border-left: 3px solid var(--colour-error);
background: var(--colour-error-subtle);
}
/* Show the error message only when relevant */
.form-error { display: none; }
.form-group:has(input:not(:placeholder-shown):invalid) .form-error {
display: block;
colour: var(--colour-error);
}
/* Success state */
.form-group:has(input:not(:placeholder-shown):valid) {
border-left: 3px solid var(--colour-success);
}
/* Float label pattern - pure CSS */
.form-group:has(input:focus) label,
.form-group:has(input:not(:placeholder-shown)) label {
transform: translateY(-1.5rem) scale(0.85);
colour: var(--colour-primary);
}The :placeholder-shown trick combined with :has() gives us interaction-aware validation styling that previously required JavaScript state management. The browser already knows this state - we just could not access it from the parent before.
/* Style a pricing card based on a toggle inside it */
.pricing-card:has(input[type="checkbox"]:checked) {
background: var(--colour-primary);
colour: white;
transform: scale(1.02);
}
/* Style a todo item when its checkbox is checked */
.todo-item:has(input:checked) .todo-text {
text-decoration: line-through;
opacity: 0.6;
}These CSS-only patterns work beautifully with screen readers because you are using native HTML form elements, not ARIA-augmented divs. The :has() selector does not change semantics - it only changes presentation.
I recently audited a form-heavy application and found that 340 lines of JavaScript existed purely for form styling state management. Every single line was replaceable with :has(). That is not a marginal improvement. That is an entire category of code that no longer needs to exist.
"Handling Empty Containers Without Watching the DOM"
Empty states are one of those details that separate polished applications from rough ones. Until :has(), handling them properly almost always meant JavaScript.
/* Hide empty state by default */
.empty-state { display: none; }
/* Show empty state when the list has no children */
.notifications-panel:has(.notification-list:empty) .empty-state {
display: flex;
flex-direction: column;
align-items: centre;
padding: 3rem 1rem;
}
/* Hide the list container itself when empty */
.notification-list:empty { display: none; }
/* Style the panel differently when empty */
.notifications-panel:has(.notification-list:empty) {
background: var(--colour-surface-subtle);
border-style: dashed;
}The :empty pseudo-class is strict - even a single whitespace text node makes an element "not empty". Most modern frameworks handle this correctly, but server-rendered HTML can be tricky.
/* Dashboard grid that collapses empty widgets */
.dashboard-grid:has(.widget:empty) {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
/* Table that shows a message when tbody is empty */
.data-table:has(tbody:empty)::after {
content: 'No data available';
display: block;
padding: 3rem;
text-align: centre;
colour: var(--colour-text-muted);
}
/* Search results area */
.search-results:has(.result-list:empty) .search-suggestions {
display: block;
}The best empty state handling is the kind that cannot get out of sync with reality. CSS :has() combined with :empty gives you exactly that - styling that is always correct because it observes the DOM directly, not a JavaScript shadow of it.
In a recent project, I replaced an entire React component - EmptyStateWrapper, complete with its useEffect, its useState, and its prop-drilling - with four lines of CSS. The component had been a source of subtle bugs for months because the JavaScript state and the actual DOM state would occasionally diverge after rapid updates. The CSS version has had zero bugs because there is no state to diverge.
"Layouts That Adapt to Their Content Count"
Quantity queries are where :has() starts to feel genuinely magical. The concept is simple: style a container differently based on how many children it has.
/* Single item - full width, hero treatment */
.card-grid:has(> .card:only-child) {
grid-template-columns: 1fr;
max-width: 600px;
margin-inline: auto;
}
/* Two items - two equal columns */
.card-grid:has(> .card:nth-child(2)):not(:has(> .card:nth-child(3))) {
grid-template-columns: repeat(2, 1fr);
}
/* Three items - three columns */
.card-grid:has(> .card:nth-child(3)):not(:has(> .card:nth-child(4))) {
grid-template-columns: repeat(3, 1fr);
}
/* Four or more - auto-fit responsive grid */
.card-grid:has(> .card:nth-child(4)) {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}/* 1 testimonial: centred, larger */
.testimonials-grid:has(> .testimonial:only-child) {
grid-template-columns: 1fr;
max-width: 42rem;
}
/* 3 testimonials: featured first, two below */
.testimonials-grid:has(> :nth-child(3)):not(:has(> :nth-child(4))) {
grid-template-columns: repeat(2, 1fr);
}
.testimonials-grid:has(> :nth-child(3)):not(:has(> :nth-child(4))) > :first-child {
grid-column: 1 / -1;
font-size: 1.15rem;
}
/* 4+ testimonials: responsive auto-fit */
.testimonials-grid:has(> :nth-child(4)) {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}Content editors can add or remove testimonials without worrying about breaking the layout. There is no "you must have exactly 3 testimonials for this section" constraint. The CSS adapts to whatever content exists.
I have started calling these "content-aware components", and they represent a genuine shift in how I think about component design. The component does not need to know how many items it will receive. It just adapts.
"Replacing Media Queries With :has() for Component-Level Decisions"
Media queries are a blunt instrument. They know about the viewport, but they know nothing about the component. :has() addresses a different dimension: styling based on what the component contains, not just how much space it has.
/* Card with image gets horizontal layout */
.card:has(.card__image) {
display: grid;
grid-template-columns: 200px 1fr;
}
/* Card without image gets simpler vertical layout */
.card:not(:has(.card__image)) {
display: flex;
flex-direction: column;
text-align: centre;
}Container queries ask "how much space do I have?" while :has() asks "what do I contain?" Together, they give CSS components the same introspective capability that previously required JavaScript frameworks.
.article-card { container-type: inline-size; }
/* Narrow container + has image: stack vertically */
@container (max-width: 400px) {
.article-card:has(.card__image) {
grid-template-columns: 1fr;
}
}
/* Wide container + has image: side by side */
@container (min-width: 401px) {
.article-card:has(.card__image) {
grid-template-columns: 1fr 1fr;
}
}
/* Wide container + no image: full-width text */
@container (min-width: 401px) {
.article-card:not(:has(.card__image)) {
max-width: 65ch;
margin-inline: auto;
}
}Patterns like BEM modifiers (.card--has-image, .card--featured) often exist solely to communicate content state to CSS. With :has(), many of these modifiers become unnecessary. The CSS can inspect the content directly.
In component libraries I maintain, roughly 15-20% of the JavaScript logic was purely for content-aware styling. That code is now CSS, and the components are simpler, faster, and more reliable for it.
"Performance Footguns, Specificity Traps, and Where :has() Hurts"
Performance: The Elephant in the Room
Browser vendors spent years saying a parent selector was too expensive to implement. They were not entirely wrong - they were wrong about it being impossible, but they were right that it requires careful handling.
/* BAD: Extremely broad subject with complex argument */
* :has(.deeply .nested .selector:nth-child(odd):hover) {
/* Forces evaluation on every element */
}
/* BAD: :has() inside :has() */
.a:has(.b:has(.c)) {
/* Double the work, exponential matching cost */
}
/* GOOD: Scoped subject, simple argument */
.form-group:has(> input:invalid) {
/* Narrow subject, direct child, simple pseudo-class */
}Keep the subject (the element before :has()) as specific as possible. Use direct child selectors (>) inside :has() when you can. Avoid universal selectors as the subject. Avoid nesting :has() inside :has().
Specificity Behaviour
/* Specificity: (0, 2, 0) - :has() takes the specificity of #id */
.card:has(#featured-badge) {
background: gold;
}
/* Specificity: (0, 1, 1) - :has() takes specificity of .badge */
article:has(.badge) {
background: silver;
}
/* The first rule wins due to #id specificity inside :has() */The specificity of a selector inside :has() contributes to the overall specificity even though it is matching a different element. You are selecting the parent, but the specificity comes from the child selector.
Chrome DevTools matched :has() selectors show which internal element caused the match. Use the Styles panel's filter to search for :has() and verify which rules are active.
None of these issues are dealbreakers. Write it thoughtfully, scope it tightly, and test your selectors under realistic DOM sizes.
":has() Combined With :is(), :where(), :not(), Nesting, and @layer"
:has() + :is() = Elegant Multi-Matching
/* Style form groups with any invalid input */
.form-group:has(:is(
input:invalid,
select:invalid,
textarea:invalid
)) {
border-colour: var(--colour-error);
}
/* Style navigation based on what follows */
.page-header:has(+ :is(.hero-banner, .full-bleed-image)) {
position: absolute;
background: transparent;
colour: white;
}:has() + :where() = Zero-Specificity Defaults
/* Base defaults that are trivially overridable */
:where(.card):has(:where(img, video, picture)) {
/* Specificity: (0, 0, 0) */
overflow: hidden;
border-radius: var(--radius-lg);
}:has() + Native Nesting
.data-table {
border-collapse: collapse;
width: 100%;
&:has(input[type="checkbox"]) {
th:first-child, td:first-child {
width: 3rem;
text-align: centre;
}
}
&:has(tr:has(input:checked)) {
.bulk-actions { display: flex; }
}
}Modern CSS is not just gaining new features in isolation. These features are multiplicative - each one makes the others more powerful. The combination of :has(), :is(), :where(), nesting, and @layer creates an expressiveness that matches or exceeds what Sass offered, without a build step.
:has() + @layer = Architecture Control
@layer base, components, utilities;
@layer components {
.checkout-form:has(:invalid) {
.submit-btn {
opacity: 0.5;
pointer-events: none;
}
}
}The @layer combination is especially important because :has() can create high-specificity selectors that are hard to override. Layers give you explicit control over precedence, making complex :has() architectures manageable at scale.
"CSS Is Becoming a Logic Layer, and That Changes Everything"
Step back for a moment and look at what has happened to CSS in just the last three years. Container queries. Cascade layers. Native nesting. The :has() relational pseudo-class. Subgrid. View transitions. The language has undergone more meaningful evolution since 2022 than it did in the entire decade before that.
And the throughline connecting all of it is this: CSS is absorbing logic that previously lived in JavaScript.
This is not CSS "trying to be JavaScript". This is CSS reclaiming responsibility for presentation logic that should never have been pushed to JavaScript in the first place.
Every line of JavaScript you write for purely visual logic is a line that increases your bundle size, creates a potential point of failure, and couples your presentation to your application runtime. CSS :has() is not just a new selector. It is a correction of a decades-old architectural imbalance.
Consider what this means for frameworks. A significant portion of React's value proposition - and Vue's, and Svelte's - is managing UI state that drives visual changes. When CSS can express these relationships directly, the framework's role shrinks to what it should have always been: managing application state, handling side effects, and orchestrating data flow. Not toggling class names.
Across three production codebases I maintain, adopting :has() (along with container queries and modern CSS features generally) has eliminated an average of 22% of component-level JavaScript. Bundle sizes dropped correspondingly, and Core Web Vitals improved because there is less main-thread work during rendering.
The practical advice I give to teams today is this: before you write a useState, a useEffect, a computed property, or a reactive declaration for anything visual, ask yourself whether CSS can express it. Can you use :has() to react to a child's state? More often than you expect, the answer is yes.
The selector that eliminated JavaScript from my stylesheets was not just a technical feature. It was a reminder that the best code is often the code you do not have to write.
Related essays
- AI · Technical Debt
12 Months of Vibe Coding in Production: The Technical Debt Report Nobody's Publishing
We shipped more features in 12 months than in the previous three years combined. We also quietly built the most confusing codebase of my career. Here is the honest audit.
- Frontend · Dependencies
I Replaced 12 npm Packages With Browser APIs Nobody Uses. Here's What Happened.
How I audited our production app's dependency tree, replaced a dozen packages with native browser APIs, and cut our bundle by 58%. A practical guide to the platform features hiding in plain sight.
- React · State Management
React 19 Actions Are Not a State Management Solution
The new Actions API is a solid improvement for handling async mutations and form submissions. But teams are already reaching for it as a Zustand or Redux replacement, and that's a category error that will bite them at scale.