Modern CSS :has() in Real UI: Parent Selectors for Forms, Cards, Filters

Was this helpful?

You ship a UI. A week later, someone adds a helper icon inside an input wrapper, and suddenly the “invalid” red border stops showing up.
Or a product manager wants a card grid that highlights any card containing a “Deal” badge—without wiring ten different JS observers.

The quiet villain is usually the same: we keep asking CSS to style up the DOM tree, and CSS historically refused. Now it doesn’t.
:has() is the parent selector we’ve wanted for twenty years, and it’s finally usable in production—if you treat it like an ops problem, not a demo trick.

What :has() actually is (and why it’s different)

:has() is a relational pseudo-class: it matches an element if it contains something that matches the selector list you put inside.
The practical translation is simple: you can finally style a parent based on a child state.

Example: highlight a field wrapper if it contains an invalid input.

cr0x@server:~$ cat ui.css
.field:has(input:invalid) {
  border: 2px solid #c1121f;
  background: #fff5f5;
}

That reads like English. Which is the main reason it’s dangerous: it makes hard things look easy.
:has() changes how you think about DOM structure, component boundaries, and state propagation.
If you use it well, you delete JS. If you use it lazily, you create “why is the entire page repainting?” tickets.

What it’s not

  • Not a replacement for good HTML. If your DOM is a junk drawer, :has() just helps you locate the junk faster.
  • Not an excuse to stop using classes. Your future self wants stable hooks for testing and refactors.
  • Not magic. It’s still selector matching, still affected by invalidation and style recalculation.

One operational framing that works: treat :has() like adding a new dependency edge in your state graph.
When a child changes, the parent’s styling might need recalculation. That’s the whole point. It’s also the cost.

A paraphrased idea often attributed to engineering reliability circles: Optimize for debuggability first; performance tuning is easier than reasoning about a black box.
That’s the posture to take with :has(). Use it to reduce opaque JS state, but keep the selectors legible and bounded.

Interesting facts and context you can repeat in design reviews

You’re going to need to defend :has() in code review, and you’ll get the classic questions: “Is it supported?” “Is it slow?”
“Why not just add a class?” Here are the useful bits of context.

  1. CSS got :has() through Selectors Level 4. The “parent selector” request is ancient; it took a while because it touches performance and invalidation.
  2. jQuery had a :has() selector long before CSS did. That’s partly why engineers kept assuming native CSS would eventually do it “the same way.” It doesn’t.
  3. Browsers resisted for years because it’s “reverse dependency.” Traditionally, style dependencies flowed from parent to child; :has() can invert that.
  4. Safari shipped it early. This surprised people who still think Safari is perpetually behind. It isn’t—at least not on this one.
  5. Browser engines had to improve invalidation tracking. Efficiently knowing which ancestors might match a selector when a node changes is real engineering, not a shrug.
  6. :has() enables “state styling” without extra DOM. Before it, teams often added wrapper elements purely for styling hooks; that’s basically technical debt in HTML form.
  7. It pairs well with modern form pseudo-classes. :user-invalid, :invalid, :required, :placeholder-shown, and :focus-within become more useful when you can bubble their effect up.
  8. It plays nicely with ARIA state. Targeting [aria-expanded="true"] or [aria-invalid="true"] inside :has() maps nicely to accessible UI state.

Joke #1: People call :has() the “parent selector,” which is accurate—like calling a data center a “room with computers.”

Forms: validation, required fields, and “just works” error wrappers

Real form UIs are not a single input and a submit button. They’re wrappers, icons, labels, helper text, server errors, client constraints,
and the moment someone adds an inline “eye” toggle inside a password field.

The stable pattern is: style the wrapper, not the input. But the wrapper needs to react to the input’s state. That’s :has() territory.

Wrapper-level invalid state

cr0x@server:~$ cat form.css
.field {
  border: 1px solid #ccd5e1;
  border-radius: 10px;
  padding: 10px;
  display: grid;
  gap: 6px;
}

.field:has(input:invalid) {
  border-color: #c1121f;
  background: #fff5f5;
}

.field:has(input:invalid) .hint {
  color: #c1121f;
}

.field:has(input:focus-visible) {
  box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.2);
}

This avoids the common trap: styling only the input while the actual clickable area is the wrapper.
Now your error ring includes the icon button, the select dropdown arrow, the prefix label—everything.

Required field labeling without extra classes

You can mark required fields based on the presence of :required children.

cr0x@server:~$ cat required.css
.field label::after { content: ""; }

.field:has(:required) label::after {
  content: " *";
  color: #c1121f;
}

This is where you must be disciplined: if your wrapper might contain multiple inputs (e.g., a date range),
decide whether “required” should reflect any required input or all required inputs. Then encode it.
Otherwise you ship inconsistent star spam.

Server errors and ARIA-driven styling

Client-side validity is not the whole story. In production, the server rejects things: uniqueness constraints, policy rules,
“that coupon code is from 2019, please stop.” These states often come back as ARIA attributes in your rendered HTML.

cr0x@server:~$ cat aria.css
.field:has([aria-invalid="true"]) {
  border-color: #c1121f;
}

.field:has([aria-invalid="true"]) .hint {
  color: #c1121f;
  font-weight: 600;
}

This is also a good place to set a team policy: prefer ARIA state to custom “data-error” attributes
when it describes the same thing. It keeps accessibility and styling aligned.

Conditional helper UI: show “caps lock on” warning only when it exists

You can conditionally allocate space and avoid layout shift by styling based on presence of a helper element.
No JS required. Also: no empty placeholders.

cr0x@server:~$ cat helper.css
.field .caps-warning { display: none; }

.field:has(.caps-warning[data-visible="true"]) .caps-warning {
  display: block;
  color: #8a6d3b;
}

If you’re doing it right, the JS only sets data-visible on the warning itself.
The wrapper reacts, and you’re not wiring parent classes through half your component tree.

Cards: content-aware styling without JS glue

Cards are where UI teams go to die slowly. Marketing wants badges, editorial wants a subtitle,
product wants the whole card clickable but also has a bookmark icon that should not navigate.
Then you add A/B tests that insert random labels. Great.

:has() lets you style the card based on what’s inside—without making the template system inject styling classes everywhere.
The right approach is to use :has() for component-internal decisions. If state comes from outside the component,
you still want explicit classes/props.

Highlight cards that contain a “Deal” badge

cr0x@server:~$ cat cards.css
.card {
  border: 1px solid #e5e7eb;
  border-radius: 14px;
  padding: 14px;
  background: white;
}

.card:has(.badge--deal) {
  border-color: #f59e0b;
  box-shadow: 0 8px 26px rgba(245, 158, 11, 0.18);
}

.card:has(.badge--deal) .title {
  color: #92400e;
}

You didn’t add a .card--deal class. You didn’t change the backend. You didn’t write a “scan DOM for badges” script.
You just styled a real component based on real content.

Cards with actions: change padding when action row exists

Classic issue: some cards have a footer with buttons; some don’t. Padding ends up awkward.
Previously teams solved this with duplicate templates or “hasFooter” props. Now it’s just CSS.

cr0x@server:~$ cat card-footer.css
.card { padding-bottom: 14px; }

.card:has(.card__actions) {
  padding-bottom: 10px;
}

.card:has(.card__actions) .card__actions {
  margin-top: 12px;
  border-top: 1px solid #eef2f7;
  padding-top: 10px;
}

Notice the scope: we only look for .card__actions inside .card.
That scoping is the first “performance” decision you make.

Clickable cards without broken nested links

Many teams wrap the entire card in an anchor tag. Then they embed other anchors, and you get invalid HTML or weird click behavior.
The better pattern is: keep a primary link inside, and style the card when it contains that link.

cr0x@server:~$ cat clickable-card.css
.card:has(a.card__primary-link:hover) {
  transform: translateY(-1px);
  box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}

.card:has(a.card__primary-link:focus-visible) {
  outline: 3px solid rgba(0, 120, 212, 0.35);
  outline-offset: 3px;
}

This yields the “whole card feels interactive” effect without committing HTML crimes.
The link remains the semantic target. The card reacts visually.

Filters and faceted search: toggles, counts, and stateful panels

Filters are production reality: lots of checkboxes, toggles, pills, and “Clear all” logic.
UI state tends to sprawl: the sidebar needs to know if anything inside it is checked; each filter group needs a “dirty” indicator;
the apply button should enable only when changes exist; and the summary row should show counts.

:has() won’t compute counts (CSS isn’t a query engine), but it can solve the binary questions that drive a lot of UI polish:
“is anything selected?”, “is this group active?”, “does this group contain an invalid input?”, “is the panel expanded?”

Mark active filter groups if any checkbox is checked

cr0x@server:~$ cat filters.css
.filter-group {
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 10px;
}

.filter-group:has(input[type="checkbox"]:checked) {
  border-color: #2563eb;
  background: #eff6ff;
}

.filter-group:has(input[type="checkbox"]:checked) .filter-group__title::after {
  content: " (active)";
  font-weight: 600;
  color: #2563eb;
}

You just eliminated a whole category of JS: iterating groups, toggling classes, handling DOM mutations.
The markup drives the state. That’s the correct direction of travel.

Enable “Clear filters” button when anything is selected

This is a classic friction point: UX wants the button disabled until it’s meaningful.
You can style the button based on checked inputs in the container.

cr0x@server:~$ cat clear.css
.filters .clear {
  opacity: 0.4;
  pointer-events: none;
}

.filters:has(input:checked) .clear {
  opacity: 1;
  pointer-events: auto;
}

It’s not just pretty. It prevents no-op clicks and reduces backend noise (“clear” being spammed with no filters set).
Small UI improvements show up as measurable operational calm.

Accordion state styling with ARIA

cr0x@server:~$ cat accordion.css
.filter-group:has(button[aria-expanded="true"]) {
  box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}

.filter-group:has(button[aria-expanded="true"]) .filter-group__chevron {
  transform: rotate(180deg);
}

Again: ARIA attribute is the state. CSS reacts. JS only toggles aria-expanded on the button,
which it should be doing anyway for accessibility.

Joke #2: The best feature of :has() is that it reduces “tiny state machines” in JS—because you definitely needed fewer of those.

Performance mental model: when :has() is cheap vs. spicy

The fear around :has() is not irrational. It can increase the work the browser does when the DOM changes,
because it introduces selectors whose match depends on descendants.

But the real world is nuanced: plenty of UIs are bottlenecked on JavaScript, layout, images, or network.
If :has() deletes JS observers and reduces re-renders, it can be a net win.
The question isn’t “is :has() slow?” The question is “did you make it unbounded?”

Do: scope :has() to component roots

Good: .field:has(input:invalid), .card:has(.badge--deal), .filters:has(input:checked).
These are bounded by a component root class and typically cover a small subtree.

Don’t: write global selectors that scan the world

Bad: body:has(input:invalid) (you just made the entire page respond to any invalid input),
or main:has(.badge) when there are thousands of badges.

Know what triggers recalculation

  • Changing attributes that affect the inner selector (e.g., toggling aria-expanded, checked, disabled).
  • Adding/removing descendant nodes that match the inner selector (DOM mutations).
  • State changes like :hover, :focus, :invalid—these can happen a lot.

Operational rule of thumb

If the inner selector can change on every mouse move (:hover) across a large subtree, treat it as a performance risk.
If it changes only on discrete actions (checkbox toggles, form submit, expansion), it’s usually fine.

This isn’t hand-waving. It’s the same thing we do in ops: you can afford expensive work on deployments and incidents.
You can’t afford it on every request.

Practical tasks (commands, output, and the decision you make)

You asked for production-grade. That means you need repeatable checks, not vibes.
Below are practical tasks you can run locally or in CI to validate :has() usage, performance risk, and fallback behavior.
Each includes: a command, what the output means, and the decision you make.

Task 1: Find :has() usage across the repo

cr0x@server:~$ rg -n --hidden --glob '!**/node_modules/**' ':has\(' .
src/styles/forms.css:12:.field:has(input:invalid) {
src/styles/cards.css:41:.card:has(.badge--deal) {
src/styles/filters.css:7:.filters:has(input:checked) .clear {

Output means: You have three selector sites using :has().
Decision: Require each use to be scoped to a component root class and reviewed for inner selector volatility.

Task 2: Detect risky global :has() patterns

cr0x@server:~$ rg -n ':has\(' src/styles | rg -n '^(html|body|main|#app|\.app|\.page)'
src/styles/layout.css:3:body:has(.modal-open) {

Output means: There’s a global body:has(...) usage.
Decision: Either justify it (modal open state might be OK if the matching element is stable) or refactor to a more explicit state class on body.

Task 3: Verify build tooling doesn’t “polyfill” :has() into nonsense

cr0x@server:~$ node -p "require('./package.json').browserslist"
[ 'defaults', 'not IE 11', 'maintained node versions' ]

Output means: Your Browserslist is modern. Good.
Decision: Confirm your CSS pipeline is not attempting to transform :has(). Prefer leaving it intact; partial polyfills can be worse than no support.

Task 4: Check what your compiled CSS actually contains

cr0x@server:~$ ls -lh dist/assets/*.css
-rw-r--r-- 1 cr0x cr0x 182K dist/assets/app.css

Output means: You have a compiled stylesheet to inspect.
Decision: Grep it to ensure :has() selectors survive minification and aren’t duplicated excessively.

Task 5: Confirm :has() survived compilation/minification

cr0x@server:~$ rg -n ':has\(' dist/assets/app.css | head
1:.field:has(input:invalid){border-color:#c1121f}
1:.filters:has(input:checked) .clear{opacity:1;pointer-events:auto}

Output means: The selector is present in shipped assets.
Decision: Proceed with progressive enhancement; don’t rely on a build-time rewrite.

Task 6: Measure CSS size deltas when removing JS state classes

cr0x@server:~$ gzip -c dist/assets/app.css | wc -c
42191

Output means: Gzipped CSS size is ~42KB.
Decision: If swapping JS class toggles for :has() reduces JS more than it increases CSS, it’s often a net win for page interactivity.

Task 7: Find JS code that toggles wrapper classes (candidate for deletion)

cr0x@server:~$ rg -n "classList\.add\(|classList\.toggle\(" src | rg -n "(invalid|error|has-|active|dirty)"
src/ui/formField.ts:88:wrapper.classList.toggle("is-invalid", !input.checkValidity())
src/ui/filters.ts:52:group.classList.toggle("is-active", anyChecked)

Output means: You’re manually propagating child state to parent classes.
Decision: Replace where safe with :has() and keep JS focused on behavior and accessibility state, not styling state.

Task 8: Confirm there’s no reliance on unsupported browsers in production traffic (via logs)

cr0x@server:~$ zgrep -h "User-Agent" /var/log/nginx/access.log* | head -n 3
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36

Output means: Modern engines dominate your sample.
Decision: If you still have a long tail (older enterprise browsers), design fallbacks: functional UI first, enhanced styling second.

Task 9: Sanity-check that your critical UI still works without :has()

cr0x@server:~$ chromium --user-data-dir=/tmp/chromium-has-test --disable-features=CSSHasPseudoClass https://localhost:5173/
[19023:19023:1229/120102.103129:INFO:chrome_main_delegate.cc(785)] Starting Chromium...

Output means: You launched Chromium with :has() disabled (feature flag name can vary by version).
Decision: If form errors become invisible, you failed progressive enhancement. Keep error messages visible by default; use :has() for polish.

Task 10: Record a performance trace focusing on style recalculation

cr0x@server:~$ chromium --enable-logging=stderr --v=1 https://localhost:5173/
[19077:19077:1229/120222.411283:INFO:content_main_runner_impl.cc(1007)] Starting content main runner

Output means: Chrome is logging; you still need DevTools Performance panel for a real trace.
Decision: If toggling a checkbox causes long “Recalculate Style” slices, audit selectors: reduce scope, avoid hover-based :has() on big containers.

Task 11: Lint for “selector bombs” (very large descendant selectors)

cr0x@server:~$ rg -n ':has\([^)]{60,}\)' src/styles
src/styles/legacy.css:19:.page:has(.content .grid .card .meta .badge[data-type="x"])

Output means: Someone wrote a long, brittle descendant chain inside :has().
Decision: Replace with a stable hook class like .badge--x or restructure markup. Long chains are fragility, not cleverness.

Task 12: Validate that your CSS selector logic isn’t accidentally matching multiple states

cr0x@server:~$ node -e 'const s=[".field:has(input:invalid)",".field:has(:required)","body:has(.modal-open)"]; console.log(s.join("\n"))'
.field:has(input:invalid)
.field:has(:required)
body:has(.modal-open)

Output means: This prints the selectors you intend to ship (use it in a quick CI smoke step alongside grep).
Decision: Require explicit review of any selector that targets body/html or uses highly dynamic pseudo-classes like :hover inside :has().

Task 13: Verify your component HTML contains the hooks your selectors expect

cr0x@server:~$ rg -n 'class="field"' src | head
src/pages/signup.html:21:<div class="field">
src/pages/settings.html:44:<div class="field">

Output means: Your templates have consistent wrapper classes.
Decision: Standardize wrapper class naming (.field, .filter-group, .card) so :has() stays local and predictable.

Task 14: Catch regressions by diffing DOM structure for critical components

cr0x@server:~$ git diff --stat origin/main...HEAD -- src/components/FormField.html
 src/components/FormField.html | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

Output means: The component structure changed (even if line count stays same).
Decision: Run a quick visual regression or DOM snapshot test when the structure changes, because :has() depends on it.

Fast diagnosis playbook

When someone says “after we added :has(), the page feels janky,” don’t debate. Diagnose.
The bottleneck is usually one of three things: selector scope, invalidation frequency, or layout thrash from the styles you apply.

First: identify the selector that expands the match set

  • Search for global roots: html:has, body:has, main:has, #app:has.
  • Search for broad inner selectors: :has(*:hover), :has(:focus), :has(.thing) where .thing is common sitewide.
  • Decision: scope it to a component root or add a dedicated hook class that only appears where needed.

Second: check what changes trigger recalculation

  • If it’s :hover or :focus, you’ve made it event-frequency dependent.
  • If it’s :checked or aria-expanded, it’s action-frequency dependent (usually safer).
  • Decision: avoid hover-driven :has() on large containers; keep hover effects on the hovered element itself, or a small ancestor.

Third: inspect the styles applied (the classic “looks innocent” trap)

  • Box shadows and filters can be expensive when applied widely.
  • Changing layout-affecting properties (like display, position, height) can trigger reflow. Sometimes that’s fine, sometimes it’s catastrophic.
  • Decision: keep :has() effects mostly to paint-only properties (border color, background, outline, text color) unless you’ve profiled.

Fourth: confirm fallback behavior

  • If the browser doesn’t support :has(), does the UI still communicate errors and state?
  • Decision: show error text by default (or on submit), and use :has() for wrapper styling enhancement.

Common mistakes: symptom → root cause → fix

1) “The entire page flickers when I hover a card grid.”

Symptom: Hovering any card causes noticeable repaint or jitter.

Root cause: A large ancestor uses :has(:hover) or similar, causing frequent style recalculation across a big subtree.

Fix: Apply hover styles to the hovered element directly, or restrict :has() to the card itself: .card:has(:hover) is still odd; prefer .card:hover and use :has() for non-hover state.

2) “Invalid fields aren’t highlighted on some browsers.”

Symptom: Wrapper borders don’t turn red; users miss errors.

Root cause: Relying on :has() for essential error visibility with no fallback.

Fix: Make error messages visible and inputs styled in a baseline way; treat wrapper decoration as enhancement. If you need broad support, keep a minimal .is-invalid class as a fallback.

3) “One field wrapper is red even when the input looks valid.”

Symptom: Wrapper shows invalid style, but the intended input is fine.

Root cause: The wrapper contains multiple inputs, and one hidden or unrelated input is invalid.

Fix: Narrow the inner selector to the specific control: .field:has(input.field__control:invalid). Don’t use input:invalid if you embed more inputs inside.

4) “Our card gets ‘deal’ styling because an unrelated badge is inside a nested component.”

Symptom: False positives: styling triggers when it shouldn’t.

Root cause: Inner selector too generic (e.g., :has(.badge) instead of :has(.badge--deal)).

Fix: Use explicit modifier classes for semantics. :has() is not a license for vague selectors.

5) “Clear filters button is enabled, but no filters are actually applied.”

Symptom: UI indicates state that backend doesn’t share.

Root cause: The sidebar contains checkboxes for both “applied” and “pending” selections; :has(input:checked) can’t distinguish.

Fix: Add an attribute or class to mark applied state: .filters:has(input[data-applied="true"]:checked), or separate applied vs draft DOM regions.

6) “A minor DOM refactor broke half the styling.”

Symptom: A UI rev moves markup around; visual behavior changes unexpectedly.

Root cause: :has() depends on DOM relationships; brittle descendant chains amplified that dependency.

Fix: Keep :has() selectors shallow, and rely on stable hooks inside the component. Add snapshot/visual regression tests for components with relational selectors.

Checklists / step-by-step plan

Adopting :has() safely (step-by-step)

  1. Pick one component type. Start with form fields or filter groups. Don’t do a sitewide refactor.
  2. Define the root selector. Example: .field, .filter-group, .card. If you don’t have one, add it.
  3. Choose inner selectors that are stable and specific. Example: input.field__control:invalid, .badge--deal, button[aria-expanded="true"].
  4. Make baseline UX functional without :has(). Errors must be readable. Buttons must work. No essential state should be communicated only by wrapper styling.
  5. Use @supports selector(:has(*)) for risky enhancements. It’s not always required, but it’s a clean guardrail when you’re changing layout or hiding/showing elements.
  6. Profile one interaction. Toggle a checkbox, focus inputs, expand accordion. Look for long “Recalculate Style” slices.
  7. Delete JS that only propagates classes. Keep JS that owns behavior and accessibility state.
  8. Add a regression test. Visual snapshots for the component states: default, focused, invalid, active, expanded.

Selector quality checklist (printable mental version)

  • Is the left side a component root (not body)?
  • Is the inner selector narrow (not a long descendant chain)?
  • Does the inner selector change at high frequency (hover/mouse move)? If yes, rethink.
  • Will hidden or unrelated descendants accidentally match?
  • Will this still be acceptable if the component is rendered 200 times on one page?
  • Is the UX still acceptable without :has()?

Three corporate mini-stories from the trenches

Mini-story 1: The incident caused by a wrong assumption

A team modernized a big settings page: lots of repeated form sections, nested components, and an inline “Add another” pattern.
They replaced a JS-based “invalid wrapper” mechanism with .section:has(input:invalid) and shipped it behind a small feature flag.
Looked clean. Tests passed. Everyone moved on.

Then support tickets started: “I can’t save settings; it just bounces me back.” The bug wasn’t in saving.
The page scrolled to the first invalid section on submit. That scroll logic relied on finding .is-invalid wrappers
(a class the old JS used to set). The new CSS-only invalid styling didn’t set the class—because it was CSS.

The wrong assumption was subtle: “If it looks invalid, it is invalid, and code can find it.”
But styling is not state. CSS can’t be queried reliably from JS in a way you want to maintain.
They had accidentally removed a semantic signal that other code depended on.

The fix was boring and correct: keep an explicit aria-invalid="true" attribute and update the scroll code to target that,
while keeping :has() for wrapper decoration. The class toggling JS stayed deleted. The state remained discoverable.

The lesson: use :has() to express presentation derived from state, not to replace state itself.
If other code needs to react, give it something semantic like ARIA or a data attribute.

Mini-story 2: The optimization that backfired

Another org shipped a new “catalog” page with thousands of cards (yes, thousands).
An engineer decided to reduce DOM work by removing per-card modifier classes generated server-side.
Instead of rendering .card--featured, the template would render a .badge element and let CSS do the rest:
.card:has(.badge--featured). Elegant.

A week later, performance dashboards showed worse interaction latency during scrolling and filtering.
Not a total fire, but enough to annoy mobile users. DevTools traces showed higher style recalculation costs during list updates.
The reason wasn’t mystical: the filter UI updated the DOM frequently, and each update meant more selector matching work
across a huge list.

The “optimization” also had a hidden cost: badge markup was more dynamic than the old modifier class.
A/B tests inserted new badge types, and now multiple :has() rules competed. The cascade got complicated.
The CSS stayed correct, but you had to be a wizard to predict it.

The rollback strategy was pragmatic: keep :has() for small lists and local components,
but restore explicit modifier classes for massive repeated lists. CSS stayed simpler, and the engine did less relational matching.

The lesson: :has() is not automatically cheaper than a class. On big collections with frequent DOM churn,
explicit state classes can be a better performance trade.

Mini-story 3: The boring but correct practice that saved the day

A payments UI team introduced :has() to improve their form wrappers. They were cautious: every rule sat behind
@supports selector(:has(*)) and the fallback styling was intentionally “good enough.”
They also wrote a small component test that rendered the field in four states: default, focused, invalid, server error.

Six months later, a partner embedded the payment form inside a WebView with an older engine.
The wrapper styling didn’t apply. But the form still worked. Error messages were visible, focus rings existed,
and the submit flow was fine. No incident. No midnight chat messages. Just a minor “looks less polished” note.

The kicker: another team without the guardrails had shipped a similar feature elsewhere, and in older engines the error text
was hidden by default and only revealed by :has() selectors. Users couldn’t see what went wrong.
That one became a real support issue.

The boring practice wasn’t heroics. It was: progressive enhancement, explicit fallback, and tests for states.
The ops payoff was real: fewer user-visible failures in unpredictable client environments.

FAQ

1) Is :has() safe to use in production?

Yes, if you use progressive enhancement and avoid unbounded selectors. Treat it like any modern platform feature:
define a baseline experience, then enhance where supported.

2) Should I wrap :has() rules in @supports?

If the rule affects essential usability (show/hide errors, layout changes), yes. If it’s purely decorative and you’re fine with it not applying, it’s optional.
The guard looks like: @supports selector(:has(*)) { ... }.

3) Can I use :has() as a replacement for JS state?

Replace presentation state propagation, not domain state. If code needs to know something is invalid/expanded/active,
express it via attributes or classes. CSS can then derive visuals using :has().

4) Does :has() hurt performance?

It can, especially when used on large containers with frequently changing descendants.
Keep selectors scoped to component roots and avoid high-frequency pseudo-classes like :hover inside :has() on big subtrees.
Profile the specific interaction you care about.

5) Is :has() better than adding a class like .is-invalid?

It’s better when the state is already present in the DOM (e.g., :invalid, :checked, ARIA attributes) and you want to avoid JS glue.
A class is better when you’re dealing with heavy lists, cross-component state, or when JS already computes state anyway.

6) Can I use :has() to count selected filters?

Not directly. CSS can’t compute counts in a maintainable way. Use JS to compute counts, render a number, and use :has() for binary styling like “active/inactive.”

7) How do I debug :has() selectors?

Start by isolating the match: temporarily apply a loud outline to the left-hand selector, then narrow the inner selector.
Keep inner selectors short and anchored to explicit classes so you can quickly reason about why it matched.

8) Can :has() replace :focus-within?

Not replace, but complement. :focus-within is a purpose-built, efficient way to style ancestors when focus is inside.
Use it for focus. Use :has() when you need richer conditions than “any focused descendant.”

9) What’s the best first use-case for :has()?

Form field wrappers reacting to :invalid, :required, and ARIA invalid state. It deletes messy JS and immediately improves UX consistency.

Next steps you can ship this week

Do this in order, because production systems reward boring sequencing.

  1. Pick one component family: field wrappers, filter groups, or cards.
  2. Add or confirm a component root class so your :has() stays local.
  3. Implement one relational rule that deletes an existing JS class toggle. Keep the old behavior behind a feature flag if you’re cautious.
  4. Guard risky enhancements with @supports selector(:has(*)) and ensure baseline UX still communicates state.
  5. Run the repo checks: grep for global selectors, long descendant chains, and hover-heavy patterns.
  6. Profile one real interaction (filter toggling, invalid input, accordion expand) and verify style recalculation is not dominating.
  7. Add a small regression suite for key UI states that depend on :has().

:has() is one of those features that makes the platform feel like it finally caught up with how we build UI.
Use it like a grown-up: scoped, testable, profiled, with graceful degradation. Your JS bundle gets smaller, your DOM gets saner,
and your on-call rotation gets quieter—which is the only KPI that matters when it’s 2 a.m.

← Previous
Intel vs AMD in games: where benchmarks mislead you
Next →
Docker “exec format error”: Wrong-Arch Images and the Clean Fix

Leave a comment