Key insights
:has()is the parent selector..plan:has(:checked)styles the whole card when its own radio is checked. For twenty years CSS only looked down and forward; now it looks up. Every major browser supports it since 2023.- Validation without a handler:
.field:has(:user-invalid)turns the border, label and icon at once. No onChange, no error state in React. The browser already knows the email is wrong, so let it drive the styling. - Escalate to the form:
form:has(:user-invalid) button { opacity: .4 }greys out the submit button while any field is invalid. One rule, zero derived state. - Layout follows the DOM:
.app:has(aside) { grid-template-columns: 280px 1fr }grows a column when a sidebar renders and collapses when it is removed. No showSidebar prop to thread through. - Quantity queries live in CSS:
.grid:has(> :nth-child(n + 5))tightens gap, padding and font size across all cards the moment a fifth one arrives. No counting items in JavaScript. - The document reacts to a modal:
body:has(dialog[open]) { overflow: hidden }locks scroll and a second rule dims the app behind it. Close the dialog and everything reverts on its own. No cleanup effect.
Do / Don't
- Do: Reach for
:has()when the state already lives in the DOM: checked, open, invalid, child count. - Do: Use
:user-invalidinstead of:invalidso errors show after the user leaves the field, not on first render. - Do: Put the rule on the container so border, label and icon all update from one selector.
- Don't: Mirror DOM state into React state just to toggle a class the browser can already compute.
- Don't: Write a cleanup effect for scroll lock or dimming that a
body:has(dialog[open])rule undoes by itself. - Don't: Count children in JavaScript to pick a layout when a quantity query does it in one rule.