The Class Toggle That Shouldn't Exist
Open your codebase and search for classList.toggle. Most of those calls are a simple pattern: user interacts with a child element (checkbox, input, button), and you toggle a class on a parent to style it. That's a bridge JavaScript shouldn't have to build. CSS :has() burns that bridge.
What :has() Actually Does
:has() doesn't just select a parent based on a child. It tests a condition against an element's contents. You can check for descendants, siblings, counts, or absence. The mental model is conditional reading of live DOM state, not parent selection.
/* Classic parent selector */
.card:has(img) { border: 2px solid gold; }
/* Focus state deep in subtree */
.form-group:has(:focus-visible) { outline: 2px solid blue; }
/* Checked state */
.option-row:has(input:checked) { background: var(--selected-bg); }
/* Absence */
.card:not(:has(img)) { opacity: 0.5; }
/* Count-based grid */
.grid:has(> :nth-child(4)) { grid-template-columns: repeat(4, 1fr); }
The last example changes a grid layout based on how many children it holds — pure CSS, no JavaScript. That's not parent selection; it's a stylesheet reading its own document.
Three Patterns to Delete JavaScript Today
1. Field-group Validation
Before:
input.addEventListener('blur', () => {
if (input.validity.valid === false && input.value !== '') {
group.classList.add('has-error');
}
});
.group.has-error { border-color: red; }
.group.has-error label { color: red; }
After (two CSS rules, zero JS):
.field-group:has(input:invalid:not(:placeholder-shown)) {
border-color: var(--color-error);
}
.field-group:has(input:invalid:not(:placeholder-shown)) label {
color: var(--color-error);
}
The browser's native validation state drives the style directly. No blur listener, no class, no sync issues.
2. Empty State
Before:
const observer = new MutationObserver(() => {
if (list.children.length === 0) {
emptyState.style.display = 'block';
} else {
emptyState.style.display = 'none';
}
});
After (one CSS rule):
.todo-list:not(:has(li)) + .empty-state { display: block; }
The empty state appears the moment the last li leaves. No JavaScript, no observer, no count tracking.
3. Checkbox-Driven Panel
Before:
checkbox.addEventListener('change', () => {
panel.classList.toggle('visible', checkbox.checked);
});
.panel.visible { display: grid; }
After:
.settings-section:has(input[type="checkbox"]:checked) .settings-panel {
display: grid;
}
The browser's own form state is the source of truth. CSS reads it directly. No syncing.
Performance: When to Be Careful
:has() isn't free. Matching a class is reading an attribute; matching :has() evaluates the inner selector against descendants. For static content and low-frequency state (validation, toggles, checked state), the cost is imperceptible. Ship it.
Be deliberate with high-frequency, continuously-changing inputs. :has() keyed off scroll position, or nested inside :hover on thousands of rows, can cause jank. Rule of thumb: if you'd debounce the JS version, profile the :has() version.
Browser Support
:has() is in Chrome 105+, Firefox 121+, Safari 15.4+, and Edge 105+. No flag, no polyfill, no waiting. It's production-ready today.
The Real Cleanup
Run that classList.toggle search again. For every call that exists purely to drive styling off some child's state, ask: "Does :has() express this condition directly?" More often than you'd guess, it does. When it does, the CSS shrinks, the JS evaporates, and the styling is correct from the first paint because it reads state instead of reacting to it.
Go delete some code. Drop the gnarliest :has() replacement in the comments.
