我刚接触 :has() 的时候还以为是在开玩笑,毕竟“父母的样式取决于孩子”,这在 CSS 里从来没存在过。但它真成了,而且 Chrome 105、Safari 15.4、Firefox 121 一早已支持。也就是说,2026 年这玩意儿已经可以在生产环境放心用。今天不聊那些理论,直接甩几个我能用上的真实案例。
1. 表单验证的视觉反馈,再也不写 JS 类名
以前做一个必填输入框,校验失败要加个红色边框,通常是在 JS 里 addClass,或者用 input 事件去 toggle。现在有了 :has(),完全可以让浏览器自己判断。
看看这段 HTML:
<div class="field">
<label for="name">用户名</label>
<input id="name" required placeholder="至少3个字符" pattern=".{3,}">
</div>
然后配合 CSS,只要输入框非法,整个表单字段的边框和标签都变红;输入合法后就变成绿色的。
/* 普通状态 */
.field {
padding: 8px;
border: 1px solid #ddd;
border-radius: 6px;
transition: border-color 0.2s;
}
/* 当子元素 input 值非法时,给 .field 加个红边 */
.field:has(input:invalid) {
border-color: #e74c3c;
}
/* 当 input 值合法时,加绿边 */
.field:has(input:valid) {
border-color: #2ecc71;
}
/* 顺手把 label 也换颜色 */
.field:has(input:invalid) label {
color: #e74c3c;
}
.field:has(input:valid) label {
color: #2ecc71;
}
注意 :valid 对空文本但带 required 的输入框是不成立的,所以未填写时是非法状态,体验没问题。复杂的表单校验可以继续组合伪类,完全不用动 JS。
2. 卡片悬停互动,兄弟元素优雅退让
很多 UI 里有这种效果:鼠标悬浮在一张卡片上,其他卡片自动降低透明度。以前要监听 mouseenter 然后给父容器加类名,现在代码短到离谱。
假设卡片组结构:
<div class="card-group">
<div class="card">内容 A</div>
<div class="card">内容 B</div>
<div class="card">内容 C</div>
</div>
写这么几行就有那种“鼠标指着谁谁清晰,别人变得透明”的互动体验:
.card-group:has(.card:hover) .card:not(:hover) {
opacity: 0.6;
transform: scale(0.98);
filter: grayscale(30%);
transition: all 0.2s;
}
.card-group {
display: flex;
gap: 16px;
}
.card {
background: #f8f9fa;
padding: 20px;
border-radius: 12px;
}
简单说:当 .card-group 的下一层里存在一个 hover 状态的 .card 时,就把所有没有 hover 的 .card 变淡。这比老办法减少了好几行 JavaScript,而且不会干扰触屏设备的点击事件。
3. 子元素数量变化,动态切换布局
有时候列表里的元素数量会随机变化,比如购物车里的优惠券数量从 1 变成 5。如果数量太少一行平铺很清爽,数量多了就自动变成两列。用媒体查询显然做不到,因为容器宽度是固定的,只有内容数量在变。
这里就是 :has() 与 :nth-child() 配合的高光时刻。
<div class="tag-list">
<span class="tag">React</span>
<span class="tag">Vue</span>
<span class="tag">Angular</span>
<span class="tag">Svelte</span>
<span class="tag">Solid</span>
</div>
CSS 里判断标签数大于等于 5 时,让列表变成网格布局,每个标签占两列。少于 5 个就让它们横着排。
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* 根据子元素数量改变父容器布局 */
.tag-list:has(> .tag:nth-child(n+5)) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.tag-list:has(> .tag:nth-child(n+5)) .tag {
text-align: center;
}
.tag {
background: #eef2ff;
padding: 6px 12px;
border-radius: 20px;
}
原理是 :has() 里用了 > .tag:nth-child(n+5),只要存在第 5 个 .tag,整个父级就切换成网格。这种条件完全由内容自己驱动,放在组件里特别合适。
4. 与容器查询一起用,做组件级响应
容器查询(Container Queries)允许你在组件内部根据父容器的宽度调整样式。但有时候,你想根据“组件里有没有某个特殊元素”来变化,比如某个卡片组件里可能有一个“推荐”徽章,有徽章时卡片标题要大一点,或者某个插图要放在右侧。两种能力叠加,代码会很清爽。
先给容器设定查询条件:
<div class="product-card">
<div class="badge">限时秒杀</div>
<h2 class="title">无线降噪耳机</h2>
<a class="buy-now">立刻购买</a>
</div>
.product-card {
container-type: inline-size;
/* 其他样式 */
}
/* 默认标题字号 */
.product-card .title {
font-size: 1.2rem;
}
/* 当容器宽度较窄时,标题变小 */
@container (max-width: 300px) {
.product-card .title {
font-size: 1rem;
}
}
/* 注意:有徽章时,即使容器再宽,标题也强制限制大小 */
.product-card:has(.badge) .title {
font-size: 1rem;
color: #c0392b;
}
/* 有徽章且容器很窄时,间距也调整 */
.product-card:has(.badge) .buy-now {
margin-top: 20px;
width: 100%;
}
这个例子里,:has() 起到了“特性开关”的作用——组件有某个子元素,它的样式就能按另一种规则走。这和 JS 里的“条件渲染”很像,但代价为零。
结语
我并没有把 :has() 所有用法都列出来,只拿出我觉得真正能少写几行 JS 的四个方向。它最让人上瘾的地方不是能替代 JS,而是让你用 CSS 自己的方式去表达“父子关系”。其实很多交互都不用动 DOM 了,把状态弄到 class 里,然后让 :has() 自己去判断,代码会突然变得很纯粹。
如果非要说坑,那就是性能。过度使用复杂的选择器可能会让重排变慢,但日常场景远远没到那个程度。放心用它,至少可以让你的 CSS 文件里少十几个类名。

