Field Guide / advanced

Stateful UI with :has() without turning CSS into business logic

The :has() selector is powerful when it reflects document state, not hidden application rules.

:has() lets CSS style an element based on what it contains. That makes parent-aware styling possible without adding a class in many cases.

The temptation is to use it everywhere. The safer rule is this: use :has() when CSS is responding to visible document state, not when it is trying to own application logic.

Good use: form state

.field {
  --field-border: #5f625d;
  display: grid;
  gap: 0.35rem;
}

.field:has(input:invalid) {
  --field-border: #d85d3f;
}

.field:has(input:focus-visible) {
  --field-border: #0f8b8d;
}

.field input {
  border: 2px solid var(--field-border);
}

The selector mirrors document state: invalid, focused, checked, disabled. That is exactly where :has() is useful.

The accessibility requirement still matters. A red border is not a complete error message. Use real text, associate it with the control, and treat the CSS as presentation.

Good use: content-aware layout

.media-card {
  display: grid;
  gap: 1rem;
}

.media-card:has(img) {
  grid-template-columns: 10rem minmax(0, 1fr);
}

This can be reasonable when the card’s layout really depends on whether media exists. The markup already contains that fact.

Be careful with optional content that may load late. If an image is injected after initial render, the layout may shift. Reserve space or choose a layout that can tolerate the change.

Good use: selected and expanded states

Native controls and ARIA state can be useful hooks.

.filter-chip:has(input:checked) {
  background: #e8f3f3;
  border-color: #0f8b8d;
}

.accordion:has([aria-expanded="true"]) {
  border-color: #0f8b8d;
}

The state still belongs to the input or button. CSS is only styling the wrapper. This pattern can reduce class toggling, but it should not replace correct interaction semantics. An accordion still needs a button. A filter still needs a real input or stateful control.

Risky use: product rules

.plan:has([data-tier="enterprise"]) {
  order: -1;
}

This may work, but it hides a product decision in CSS. If enterprise plans should sort first, the data or template should own that order. CSS can style the result.

Use the same judgment for permissions, personalization, pricing, and business-critical states. CSS should not decide what the user is allowed to do. It can reflect state that is already represented by meaningful markup.

Specificity and readability still matter

:has() contributes specificity based on its argument. A selector can become harder to override than expected.

.card:has(.card__media img) {
  padding-block-start: 0;
}

That may be fine, but it is not a free parent selector. Keep the argument simple and close to the component boundary.

Modern engines handle :has(), but selector clarity still matters. A rule like .app:has(main article .card form input:checked) ties distant parts of the document together. It is hard to debug because a small form change can alter a high-level app wrapper.

Prefer local selectors:

.field:has(input:invalid) { }
.tabs:has([aria-selected="true"]) { }
.card:has(> img) { }

These selectors describe component state. They do not turn the whole page into a dependency graph.

A useful team guideline

If the selector can be explained as “style this component when it contains this visible state,” it is probably a good fit. If it sounds like “change product behavior when a distant data condition exists,” move the decision out of CSS.

If :has() styling is essential to usability, make sure the interface still works without it. The control state, labels, validation text, and layout should remain understandable. :has() should enhance the visual presentation.

Keep state local and inspectable

The best :has() selectors are easy to verify in DevTools. You can inspect the component, see the checked input, invalid control, selected tab, or present media, and understand why the wrapper changed. If you have to inspect distant application state or hidden data attributes to explain the selector, CSS is probably carrying too much responsibility.

Use :has() to remove glue code, not to hide intent. A form field that changes border color when its input is invalid becomes simpler. A card that reorders itself because it contains a certain data tier becomes more mysterious. The difference is ownership: validation state belongs in the control; business ordering belongs in data or templates.

Also account for late content. A :has(img) rule can change layout after an image or optional slot is inserted. If that shift would be jarring, reserve the media region up front or choose a base layout that tolerates both states. Parent-aware styling does not remove the need for stable layout boundaries.

Related guides: Cascade layers, Custom properties, and Alignment in CSS.

References