纯CSS实现三级级联菜单:10行代码搞定效果
遇到了一个经典需求:三级级联菜单,点击父级展开子级,层层下钻。产品经理的原型里,这效果看着挺高级,实现起来却让人头大。传统的做法是:每个菜单项绑定点击事件,维护一个activeMenu状态,再通过JS控制显示隐藏。50行代码起步,状态管理还得小心翼翼。
分享一个纯CSS实现方案,只需10行代码搞定。
先看效果
不卖关子,直接看代码:
<details>
<summary>网络设置</summary>
<details>
<summary>移动网络</summary>
<details>
<summary>数据漫游</summary>
<p>开启后可在境外使用移动数据...</p>
</details>
</details>
</details>配合一段CSS:
@scope (:has(> details[open])) to (details[open]) {
* {
display: none;
}
}就完成了层级下钻效果。展开一级,二级显示;展开二级,一级自动收起。
关键原理:Donut Scope
这个技巧的核心是CSS的@scope规则。但很多人(包括我自己一开始)对@scope的理解是错的。
误区:@scope是用来替代BEM命名规范的工具,用来限制样式作用范围。
真相:@scope真正的威力在于“条件性作用域控制”。
它的语法是这样的:
@scope (scope-root) to (scope-limit) {
/* 这里的样式只影响scope-root和scope-limit之间的元素 */
}形象地说,这就像是一个“甜甜圈”(donut)——中间的scope-limit不受影响,只影响外围区域。
在我们的菜单场景里:
Scope Root: :has(> details[open]) —— 包含一个展开details的父元素
Scope Limit: details[open] —— 那个被展开的details本身
效果: 隐藏父details里除了当前展开项之外的所有内容
这就是“下钻”效果的魔法所在。用户点击展开子菜单时,父级菜单的其他内容被隐藏,只剩下当前展开的子菜单链。
完整代码
这是完整的可运行代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
body {
font-family: system-ui, sans-serif;
max-width: 400px;
margin: 50px auto;
padding: 20px;
}
details {
border: 1px solid #ddd;
border-radius: 8px;
margin: 8px 0;
overflow: hidden;
}
summary {
padding: 12px 16px;
background: #f5f5f5;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
}
summary::before {
content: '▶';
font-size: 0.8em;
transition: transform 0.2s;
}
details[open] > summary::before {
transform: rotate(90deg);
}
details details {
margin: 0;
border: none;
border-top: 1px solid #eee;
}
/* 核心:donut scope */
@scope (:has(> details[open])) to (details[open]) {
* {
display: none;
}
}
details details details p {
padding: 16px;
margin: 0;
color: #666;
}
</style>
</head>
<body>
<h2>设置</h2>
<details>
<summary>网络设置</summary>
<details>
<summary>移动网络</summary>
<details>
<summary>数据漫游</summary>
<p>开启后可在境外使用移动数据。可能产生额外费用。</p>
</details>
</details>
<details>
<summary>Wi-Fi</summary>
<p>已保存的网络:Home_5G, Office_WiFi</p>
</details>
</details>
</body>
</html>你可以在Chrome 118+或Safari 17.4+里直接运行这段代码。
遇到这些坑
当我兴冲冲地把这个方案发到群里,说“终于不用写JS了”时,一位同事的回复让我瞬间冷静:
“Firefox呢?”
对啊,Firefox呢?
坑1:Firefox完全不支持
截止到2026年4月,@scope在Firefox里完全不支持。
@supports (@scope (:has(.test)) to (.test)) {
/* 这段代码在Firefox里不会执行 */
}这意味着如果你的用户里有Firefox用户(哪怕是5%),这个方案就用不了。不是降级 gracefully,是直接挂掉。
坑2::has()的兼容性问题
我们的代码里还用了:has()选择器。虽然:has()的支持度比@scope好,但在Safari 15.4之前的版本也不支持。
如果你的目标用户还在用旧版macOS,这又是个问题。
坑3:动画和过渡不友好
你可能注意到了,代码里只有summary::before的旋转有transition,菜单的展开收起是瞬间完成的。
这是因为display: none不支持transition。如果你想做平滑的展开收起动画,还是得用JS控制max-height或opacity。
坑4:可访问性考虑
<details>元素虽然有原生的可访问性支持,但这种“下钻”模式其实改变了默认的交互逻辑。屏幕阅读器用户可能会困惑:“为什么我展开了一个菜单,其他的内容不见了?”
如果你的产品对可访问性要求高,这个方案需要额外测试和优化。
爬坑建议
折腾了一下午,把坑都踩了一遍,我的结论是:
@scope方案不是不能用,但得看场景。
| 场景 | 建议 | 原因 |
|---|---|---|
| 内部工具/后台系统 | 可以用 | 浏览器可控,开发速度快 |
| 原型/概念验证 | 可以用 | 10行代码搞定,快 |
| Chrome-only环境 | 可以用 | Electron、Chrome Extension |
| 面向公众的Web产品 | 不建议 | Firefox不支持,兼容性风险 |
| 需要平滑动画 | 不建议 | display: none不支持transition |
| 对可访问性要求高 | 不建议 | 需要额外测试和优化 |
对比传统的JS方案:
// 传统JS方案
const menuItems = document.querySelectorAll('.menu-item');
menuItems.forEach(item => {
item.addEventListener('click', (e) => {
// 管理active状态
// 控制显示隐藏
// 处理动画
});
});JS方案虽然代码多,但兼容性好、可控性强。如果只是简单的三层菜单,用JS并不复杂。
总结
@scope确实很酷。这个“Donut Scope”模式展示了现代CSS的创造力——用声明式的方式解决原本需要JS的问题。
纯CSS方案能少写代码,但前提是浏览器支持。如果你的用户覆盖Firefox,或者需要复杂的交互逻辑,还是乖乖写JS吧。
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!