纯CSS实现三级级联菜单:10行代码搞定效果

摘要:遇到了一个经典需求:三级级联菜单,点击父级展开子级,层层下钻。产品经理的原型里,这效果看着挺高级,实现起来却让人头大。传统的做法是:每个菜单项绑定点击事件,维护一个activeMenu状态

遇到了一个经典需求:三级级联菜单,点击父级展开子级,层层下钻。产品经理的原型里,这效果看着挺高级,实现起来却让人头大。传统的做法是:每个菜单项绑定点击事件,维护一个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吧。

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

链接: https://shenqiku.cn/article/FLY_13681