:has() Parent Interactions
Four self-contained demos where the CSS :has() relational selector styles a parent from its descendants — an invalid/checked form that highlights its own fields, a card grid that dims siblings on hover, a layout that reflows by counting selected items, and a no-JS tabs and accordion driven entirely by :has(:checked).
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
background:
radial-gradient(1100px 620px at 82% -8%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(900px 520px at 8% 108%, rgba(34, 211, 238, 0.1), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
min-height: 100vh;
}
code,
.mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.wrap {
max-width: 1040px;
margin: 0 auto;
padding: 40px 20px 64px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---------- masthead ---------- */
.masthead {
margin-bottom: 22px;
}
.eyebrow {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-2);
margin-bottom: 10px;
}
h1 {
font-size: clamp(26px, 5vw, 40px);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 12px;
}
h1 code {
color: var(--accent);
}
.lede {
max-width: 66ch;
color: var(--muted);
margin: 0 0 16px;
font-size: 15px;
}
.lede code {
color: var(--ink);
background: var(--surface-2);
padding: 1px 6px;
border-radius: 6px;
font-size: 13px;
}
.support {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface);
}
.support::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--warn);
}
.support.ok::before {
background: var(--ok);
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.16);
}
.support.no::before {
background: var(--danger);
}
/* ---------- readout ---------- */
.readout {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin: 18px 0 26px;
padding: 14px 16px;
background: linear-gradient(180deg, var(--surface-2), var(--surface));
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset;
}
.readout .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.18);
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.readout.hot .dot {
background: var(--accent-2);
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.22);
}
.readout-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.readout code {
flex: 1;
min-width: 200px;
font-size: 13px;
color: var(--accent-2);
overflow-x: auto;
white-space: nowrap;
}
/* ---------- panels ---------- */
.panels {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 20px;
transition: border-color 0.2s ease;
}
.panel:hover {
border-color: var(--line-2);
}
.panel-head {
margin-bottom: 16px;
}
.panel-head h2 {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px;
}
.panel-head p {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.panel code,
.form-note code,
.plan-status code,
.foot code {
font-size: 12px;
color: var(--accent-2);
background: var(--surface-2);
padding: 1px 5px;
border-radius: 5px;
}
/* ---------- 1. form ---------- */
.signup {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px solid var(--line);
border-left: 3px solid var(--line);
border-radius: var(--r-md);
background: var(--surface-2);
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.field-label {
font-size: 13px;
font-weight: 600;
}
.field input {
width: 100%;
padding: 9px 11px;
font: inherit;
font-size: 14px;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field input::placeholder {
color: #5c5e6c;
}
.field input:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.28);
}
.hint {
font-size: 12px;
color: var(--muted);
}
/* the star of the show: parent styled from its input */
.field:has(input:user-invalid) {
border-color: var(--danger);
border-left-color: var(--danger);
background: rgba(248, 113, 113, 0.07);
}
.field:has(input:user-invalid) .hint::after {
content: " \2014 check this field";
color: var(--danger);
}
.field:has(input:valid:not(:placeholder-shown)) {
border-color: var(--ok);
border-left-color: var(--ok);
background: rgba(52, 211, 153, 0.07);
}
.field:has(input:valid:not(:placeholder-shown)) .field-label::after {
content: " \2713";
color: var(--ok);
}
.check {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
padding: 6px 2px;
border-radius: var(--r-sm);
cursor: pointer;
transition: color 0.2s ease;
}
.check input {
width: 17px;
height: 17px;
accent-color: var(--accent);
flex: none;
}
.check:has(input:checked) {
color: var(--ok);
}
.submit {
margin-top: 2px;
padding: 11px 16px;
font: inherit;
font-weight: 600;
font-size: 14px;
color: #fff;
background: var(--surface-2);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
cursor: not-allowed;
opacity: 0.55;
transition: all 0.2s ease;
}
/* whole-form gating with chained :has() */
.signup:has(input:valid):has(input[name="pw"]:valid):has(input[name="terms"]:checked) .submit {
cursor: pointer;
opacity: 1;
background: linear-gradient(135deg, var(--accent), #6d3ff0);
border-color: transparent;
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.35);
}
.signup:has(.submit:not([disabled]):hover) .submit,
.submit:hover {
filter: brightness(1.08);
}
.form-note {
margin: 2px 0 0;
font-size: 12px;
color: var(--muted);
}
/* ---------- 2. spotlight grid ---------- */
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.card {
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface-2);
cursor: pointer;
outline: none;
transition: transform 0.22s ease, filter 0.22s ease, opacity 0.22s ease,
border-color 0.22s ease, box-shadow 0.22s ease;
}
.card-tag {
display: inline-block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-2);
background: rgba(34, 211, 238, 0.12);
padding: 2px 7px;
border-radius: 999px;
margin-bottom: 8px;
}
.card h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}
.card p {
margin: 0;
font-size: 12px;
color: var(--muted);
}
/* dim siblings when any card is hovered/focused */
.grid:has(.card:hover) .card:not(:hover),
.grid:has(.card:focus-visible) .card:not(:focus-visible) {
filter: saturate(0.35) brightness(0.7);
opacity: 0.55;
transform: scale(0.98);
}
.card:hover,
.card:focus-visible {
border-color: var(--accent);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4);
transform: translateY(-3px);
}
/* ---------- 3. count-aware layout ---------- */
.plans {
border: 0;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: 1fr;
gap: 10px;
transition: grid-template-columns 0.25s ease;
}
.plan {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface-2);
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.plan input {
width: 17px;
height: 17px;
accent-color: var(--accent);
flex: none;
}
.plan-name {
font-weight: 600;
font-size: 14px;
}
.plan-price {
margin-left: auto;
font-family: "JetBrains Mono", monospace;
font-size: 13px;
color: var(--muted);
}
.plan:has(input:checked) {
border-color: var(--accent);
background: rgba(139, 92, 246, 0.12);
}
.plan:has(input:checked) .plan-price {
color: var(--accent-2);
}
/* 2 checked -> two columns */
.plans:has(input:checked ~ label input:checked) {
grid-template-columns: repeat(2, 1fr);
}
/* 3+ checked -> highlight the whole fieldset */
.plans:has(input:checked ~ label input:checked ~ label input:checked) {
padding: 8px;
border-radius: var(--r-md);
background: rgba(34, 211, 238, 0.06);
outline: 1px solid rgba(34, 211, 238, 0.25);
}
.plan-status {
margin: 12px 0 0;
font-size: 13px;
color: var(--muted);
}
/* ---------- 4. tabs + accordion ---------- */
.tabs {
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
margin-bottom: 16px;
}
.tabstrip {
display: flex;
gap: 4px;
padding: 6px;
background: var(--surface-2);
border-bottom: 1px solid var(--line);
}
.tabstrip label {
flex: 1;
text-align: center;
padding: 8px 10px;
font-size: 13px;
font-weight: 500;
color: var(--muted);
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease;
}
.tabstrip label:hover {
color: var(--ink);
}
.tabstrip input:checked + label {
background: var(--accent);
color: #fff;
}
.tabstrip input:focus-visible + label {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
}
.tabpanels {
padding: 14px;
font-size: 13px;
color: var(--muted);
}
.tabpanel {
display: none;
}
.tabpanel p {
margin: 0;
}
/* reveal panels using :has(:checked) on the wrapper */
.tabs:has(#tab-a:checked) .tabpanel[data-for="tab-a"],
.tabs:has(#tab-b:checked) .tabpanel[data-for="tab-b"],
.tabs:has(#tab-c:checked) .tabpanel[data-for="tab-c"] {
display: block;
animation: fade 0.2s ease;
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(4px);
}
}
.accordion {
display: flex;
flex-direction: column;
gap: 8px;
}
details-row {
display: block;
}
.acc-item {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 4px 12px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface-2);
cursor: pointer;
transition: border-color 0.2s ease;
}
.acc-item:hover {
border-color: var(--line-2);
}
.acc-summary {
font-size: 13px;
font-weight: 600;
grid-column: 1;
}
.acc-body {
grid-column: 1 / -1;
font-size: 12px;
color: var(--muted);
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.28s ease, opacity 0.28s ease, margin 0.28s ease;
}
.chevron {
grid-column: 2;
grid-row: 1;
width: 10px;
height: 10px;
border-right: 2px solid var(--muted);
border-bottom: 2px solid var(--muted);
transform: rotate(45deg);
transition: transform 0.25s ease, border-color 0.25s ease;
margin-top: 3px;
}
.acc-item:has(input:checked) {
border-color: var(--accent);
}
.acc-item:has(input:checked) .acc-body {
max-height: 120px;
opacity: 1;
margin-top: 4px;
}
.acc-item:has(input:checked) .chevron {
transform: rotate(-135deg);
border-color: var(--accent);
}
/* ---------- foot ---------- */
.foot {
margin-top: 26px;
font-size: 12px;
color: var(--muted);
text-align: center;
}
/* ---------- fallback when :has() unsupported ---------- */
@supports not selector(:has(*)) {
.no-has .field,
.no-has .plan,
.no-has .acc-item {
border-left-color: var(--line);
}
.no-has .submit {
opacity: 1;
cursor: pointer;
background: linear-gradient(135deg, var(--accent), #6d3ff0);
border-color: transparent;
}
.no-has .tabpanel {
display: block;
border-top: 1px dashed var(--line);
padding-top: 8px;
margin-top: 8px;
}
.no-has .acc-body {
max-height: none;
opacity: 1;
margin-top: 6px;
}
}
/* ---------- responsive ---------- */
@media (max-width: 820px) {
.panels {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.wrap {
padding: 28px 14px 48px;
}
.grid {
grid-template-columns: 1fr;
}
.readout code {
min-width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}// script.js — JS only mirrors the live :has() state into the readout.
// All actual UI behavior is pure CSS via :has(). No event-driven styling here.
(function () {
"use strict";
var supportsHas =
typeof CSS !== "undefined" &&
CSS.supports &&
CSS.supports("selector(:has(*))");
// ---- feature detect banner ----
var banner = document.getElementById("support");
if (supportsHas) {
banner.classList.add("ok");
banner.textContent = "Native :has() is active — every panel is CSS-driven.";
} else {
banner.classList.add("no");
banner.textContent =
"This browser lacks :has() — showing a static accessible fallback.";
document.body.classList.add("no-has");
// still wire the readout so the label isn't stale
document.getElementById("selector").textContent =
"/* :has() unsupported — fallback styling applied */";
return;
}
var readout = document.querySelector(".readout");
var selectorEl = document.getElementById("selector");
var form = document.getElementById("signup");
var plans = document.getElementById("plans");
var planStatus = document.getElementById("plan-status");
var grid = document.querySelector(".grid");
var tabInputs = document.querySelectorAll('input[name="tabs"]');
var idleText = ":root { /* interact to see :has() fire */ }";
var idleTimer = null;
function show(rule, hot) {
selectorEl.textContent = rule;
readout.classList.toggle("hot", !!hot);
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(function () {
selectorEl.textContent = idleText;
readout.classList.remove("hot");
}, 2600);
}
// ---- 1. form: report which rule the field wrappers match ----
function reportForm() {
var email = form.querySelector('input[name="email"]');
var pw = form.querySelector('input[name="pw"]');
var terms = form.querySelector('input[name="terms"]');
var invalid = form.querySelector("input:user-invalid");
if (invalid) {
show(".field:has(input:user-invalid) { border-color: danger }", false);
return;
}
var complete =
email.validity.valid &&
email.value &&
pw.validity.valid &&
pw.value &&
terms.checked;
if (complete) {
show(
"form:has(:valid):has([name=terms]:checked) .submit { unlocked }",
true
);
} else {
show(".field:has(input:valid:not(:placeholder-shown)) { ok }", true);
}
}
form.addEventListener("input", reportForm);
form.addEventListener("blur", reportForm, true);
form.addEventListener("submit", function (e) {
e.preventDefault();
var btn = form.querySelector(".submit");
var label = btn.textContent;
btn.textContent = "Account created ✓";
show("form:has(:valid):has([name=terms]:checked) — submitted", true);
setTimeout(function () {
btn.textContent = label;
}, 1600);
});
// ---- 2. spotlight grid ----
grid.addEventListener("pointerover", function (e) {
if (e.target.closest(".card")) {
show(".grid:has(.card:hover) .card:not(:hover) { dimmed }", true);
}
});
grid.addEventListener("focusin", function (e) {
if (e.target.closest(".card")) {
show(".grid:has(.card:focus-visible) .card:not(:focus-visible) { dimmed }", true);
}
});
// ---- 3. count-aware layout ----
function reportPlans() {
var n = plans.querySelectorAll("input:checked").length;
var msg, rule;
if (n === 0) {
msg = "Nothing selected — single-column stack.";
rule = ".plans { grid-template-columns: 1fr }";
} else if (n === 1) {
msg = "1 plan selected — still one column.";
rule = ".plan:has(input:checked) { highlighted }";
} else if (n === 2) {
msg = "2 plans — layout splits into two columns.";
rule = ".plans:has(input:checked ~ label input:checked) { 2 cols }";
} else {
msg = n + " plans — comparison mode with cyan outline.";
rule =
".plans:has(:checked ~ label :checked ~ label :checked) { compare }";
}
planStatus.textContent = msg;
show(rule, n > 0);
}
plans.addEventListener("change", reportPlans);
// ---- 4. tabs (readout only; CSS does the switching) ----
tabInputs.forEach(function (input) {
input.addEventListener("change", function () {
var name = input.nextElementSibling.textContent.trim();
show(".tabs:has(#" + input.id + ":checked) .tabpanel { show " + name + " }", true);
});
});
// accordion readout
document.querySelectorAll(".acc-item input").forEach(function (input) {
input.addEventListener("change", function () {
show(
".acc-item:has(input:checked) .acc-body { expand }",
input.checked
);
});
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>:has() Parent Interactions</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="wrap">
<header class="masthead">
<div class="eyebrow">CSS • relational selector</div>
<h1><code>:has()</code> Parent Interactions</h1>
<p class="lede">
Four demos styled by descendants, not JavaScript. The parent reacts to
what lives inside it — invalid inputs, hovered siblings, the count
of checked options, and no-JS tabs. The live readout below mirrors the
exact <code>:has()</code> rule firing right now.
</p>
<div id="support" class="support" role="status" aria-live="polite"></div>
</header>
<!-- readout -->
<section class="readout" aria-label="Live selector readout">
<span class="dot" aria-hidden="true"></span>
<span class="readout-label">Active rule</span>
<code id="selector">:root { /* interact to see :has() fire */ }</code>
</section>
<div class="panels">
<!-- 1. Form validation -->
<section class="panel" aria-labelledby="p1-title">
<div class="panel-head">
<h2 id="p1-title">1 · Self-highlighting form</h2>
<p>Each <code>.field</code> restyles itself from the input it wraps.</p>
</div>
<form id="signup" class="signup" novalidate>
<label class="field">
<span class="field-label">Work email</span>
<input type="email" name="email" placeholder="[email protected]" required />
<span class="hint">We validate the format live via <code>:user-invalid</code>.</span>
</label>
<label class="field">
<span class="field-label">Password</span>
<input type="password" name="pw" placeholder="8+ characters" minlength="8" required />
<span class="hint">Turns green once valid.</span>
</label>
<label class="check">
<input type="checkbox" name="terms" required />
<span>I accept the terms of service</span>
</label>
<button type="submit" class="submit">Create account</button>
<p class="form-note">
The button unlocks only when
<code>form:has(:valid):has(input[name=terms]:checked)</code>.
</p>
</form>
</section>
<!-- 2. Sibling dimming grid -->
<section class="panel" aria-labelledby="p2-title">
<div class="panel-head">
<h2 id="p2-title">2 · Spotlight grid</h2>
<p>Hover one card — <code>:has(.card:hover)</code> dims the rest.</p>
</div>
<div class="grid" role="list">
<article class="card" role="listitem" tabindex="0">
<span class="card-tag">Edge</span>
<h3>Nova CDN</h3>
<p>312 POPs, 8ms median TTFB worldwide.</p>
</article>
<article class="card" role="listitem" tabindex="0">
<span class="card-tag">Data</span>
<h3>Atlas DB</h3>
<p>Serverless Postgres with branchable snapshots.</p>
</article>
<article class="card" role="listitem" tabindex="0">
<span class="card-tag">Auth</span>
<h3>Gatekeep</h3>
<p>Passkeys, SSO and device trust in one SDK.</p>
</article>
<article class="card" role="listitem" tabindex="0">
<span class="card-tag">Queue</span>
<h3>Relay MQ</h3>
<p>At-least-once delivery, 1M msg/s per shard.</p>
</article>
</div>
</section>
<!-- 3. Quantity-driven layout -->
<section class="panel" aria-labelledby="p3-title">
<div class="panel-head">
<h2 id="p3-title">3 · Count-aware layout</h2>
<p>The grid reflows by how many plans are checked.</p>
</div>
<fieldset class="plans" id="plans">
<legend class="sr-only">Choose your plans</legend>
<label class="plan">
<input type="checkbox" name="plan" value="hobby" />
<span class="plan-name">Hobby</span>
<span class="plan-price">$0</span>
</label>
<label class="plan">
<input type="checkbox" name="plan" value="pro" />
<span class="plan-name">Pro</span>
<span class="plan-price">$24</span>
</label>
<label class="plan">
<input type="checkbox" name="plan" value="team" />
<span class="plan-name">Team</span>
<span class="plan-price">$79</span>
</label>
<label class="plan">
<input type="checkbox" name="plan" value="scale" />
<span class="plan-name">Scale</span>
<span class="plan-price">$199</span>
</label>
</fieldset>
<p class="plan-status" id="plan-status">Nothing selected — single-column stack.</p>
</section>
<!-- 4. No-JS tabs + accordion -->
<section class="panel" aria-labelledby="p4-title">
<div class="panel-head">
<h2 id="p4-title">4 · No-JS tabs & accordion</h2>
<p>Radios and checkboxes drive state via <code>:has(:checked)</code>.</p>
</div>
<div class="tabs">
<div class="tabstrip" role="tablist">
<input type="radio" id="tab-a" name="tabs" class="sr-only" checked />
<label for="tab-a">Overview</label>
<input type="radio" id="tab-b" name="tabs" class="sr-only" />
<label for="tab-b">Metrics</label>
<input type="radio" id="tab-c" name="tabs" class="sr-only" />
<label for="tab-c">Changelog</label>
</div>
<div class="tabpanels">
<div class="tabpanel" data-for="tab-a">
<p>A <code>.tabs</code> wrapper checks <code>:has(#tab-a:checked)</code>
and reveals this panel — zero listeners.</p>
</div>
<div class="tabpanel" data-for="tab-b">
<p>Uptime 99.98%, p95 latency 42ms, 1.2B requests this month.</p>
</div>
<div class="tabpanel" data-for="tab-c">
<p>v4.2 shipped native <code>:has()</code> feature detection and
donut-scope theming.</p>
</div>
</div>
</div>
<div class="accordion">
<details-row>
<label class="acc-item">
<input type="checkbox" class="sr-only" />
<span class="acc-summary">Does <code>:has()</code> hurt performance?</span>
<span class="acc-body">Modern engines optimize it well for the
common child and state cases used here.</span>
<span class="chevron" aria-hidden="true"></span>
</label>
</details-row>
<details-row>
<label class="acc-item">
<input type="checkbox" class="sr-only" />
<span class="acc-summary">Can I select a parent finally?</span>
<span class="acc-body">Yes — <code>:has()</code> matches an
element based on its descendants, effectively a parent selector.</span>
<span class="chevron" aria-hidden="true"></span>
</label>
</details-row>
</div>
</section>
</div>
<footer class="foot">
Built with the native CSS <code>:has()</code> relational pseudo-class.
JavaScript only mirrors state into the readout.
</footer>
</main>
<script src="script.js"></script>
</body>
</html>This demo is a tour of the CSS :has() relational pseudo-class — the long-awaited “parent selector” — showing four patterns that previously needed JavaScript. A sign-up form styles each field wrapper from the state of the input it contains: .field:has(input:user-invalid) paints a red boundary, .field:has(input:valid:not(:placeholder-shown)) turns it green, and the submit button unlocks only when the whole <form>:has() the required boxes checked. A pricing card grid uses .grid:has(.card:hover) .card:not(:hover) so hovering any card dims and desaturates its siblings, spotlighting the focus.
The third panel is a quantity-driven layout: a plan picker whose container reflows and recolors purely from how many options are selected, using :has() combined with sibling counting so one, two, or three-plus choices each produce a different grid. The last panel is a fully no-JS component set — tabs and an accordion — where hidden radio and checkbox inputs drive visibility through :has(:checked), proving you can build stateful UI without a single event listener.
Everything visual is CSS; the only JavaScript reads the live :has() state back out to update a small readout and to render the exact selector firing at each moment, so you can watch the cascade react. A @supports selector(:has(*)) feature detect swaps in an accessible fallback banner and static styling for browsers that predate :has(), keeping every panel usable while clearly flagging when true native :has() support is active.