前不久给一个文档站点改版,产品那边要求把FAQ区域做成点击问题展开答案的折叠面板,并且同一时间只允许一个答案展开——也就是常见的手风琴组件。以前碰到这种需求,我会立刻写一个事件委托脚本,监听所有 `summary` 的点击,拿到父级 `details` 元素后遍历关闭兄弟节点。功能实现不难,但每次做新站点都要把这段逻辑再搬一遍,感觉还是有点蠢。
中间在查 MDN 其他内容时偶然瞥见 `
一、先回顾一下 details 的基础模型
`
` 子元素作为点击把手,其他内容默认隐藏,当用户点击 `
` 时,`details` 元素会获得 `open` 属性,内容显示出来。这是一个纯 HTML 的交互,无需 CSS 或 JS 参与。
<details>
<summary>这个问题的答案是什么?</summary>
<p>答案是 42。</p>
</details>
单个的 `details` 用起来已经非常方便,但一个页面中多个 `details` 之间是彼此独立的——你可以同时打开多个,这不符合手风琴的互斥要求。在 `name` 属性出现以前,要让它们互斥,只能用 JS 监听 `toggle` 事件,手动关闭兄弟元素。那段代码虽然不长,但总归是业务逻辑之外的额外心智负担。
<details>
<summary>这个问题的答案是什么?</summary>
<p>答案是 42。</p>
</details>
单个的 `details` 用起来已经非常方便,但一个页面中多个 `details` 之间是彼此独立的——你可以同时打开多个,这不符合手风琴的互斥要求。在 `name` 属性出现以前,要让它们互斥,只能用 JS 监听 `toggle` 事件,手动关闭兄弟元素。那段代码虽然不长,但总归是业务逻辑之外的额外心智负担。
二、name 属性怎么把多个 details 变成手风琴
给多个 `details` 元素加上相同的 `name` 值,浏览器就会把它们视为一个互斥组。当其中一个被打开时,同组内其他已经打开的会自动关闭。这个行为和同名的单选按钮(radio button)的互斥行为完全一致,实际上设计师就是借鉴了 radio 的工作方式。
把前面那个 FAQ 例子扩展成一组互斥的问题:
<details name="faq-group">
<summary>如何重置密码?</summary>
<p>在登录页面点击“忘记密码”,按提示操作即可。</p>
</details>
<details name="faq-group">
<summary>支持哪些支付方式?</summary>
<p>我们支持信用卡、支付宝和微信支付。</p>
</details>
<details name="faq-group">
<summary>退货期限是多久?</summary>
<p>商品签收后 30 天内可以申请退货。</p>
</details>
加载页面后,任意打开一个问题,当点击另一个问题时,前一个自动合上。整个过程不需要任何事件监听,浏览器在内部完成了状态切换。而且这个互斥行为只作用于同一 `name` 的组内,页面上可以同时存在多个不同名的互斥组,互不干扰。
三、完整实战:一个真实FAQ页面的手风琴改造
把站点原来的 FAQ 组件从 JS 方案迁移到纯 HTML 方案,改动主要在两个地方:一是去掉管理打开状态的脚本,二是给每个 `details` 加上 `name` 属性,三是调整原有 CSS 以维持视觉一致性。
旧代码使用了 `open` 属性由 JS 控制,现在 `open` 由浏览器原生管理,CSS 可以直接使用 `details[open]` 选择器来控制展开状态样式,不再需要额外的 `.active` 类名。我们给 `
<details name="support-faq">
<summary>
<span>如何联系客服?</span>
<span class="arrow">▸</span>
</summary>
<div class="answer">
<p>您可以通过在线聊天、邮件或拨打 400 客服热线联系我们。</p>
</div>
</details>
<details name="support-faq">
<summary>
<span>订单发货后如何修改地址?</span>
<span class="arrow">▸</span>
</summary>
<div class="answer">
<p>请尽快联系客服尝试拦截,已出库的包裹可能无法修改地址。</p>
</div>
</details>
样式部分(为了可读,这里用了内联的 “ 块,实际开发建议放在独立样式文件):
details {
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 8px;
padding: 0 16px;
background: #fff;
transition: all 0.2s ease;
}
summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
cursor: pointer;
list-style: none; /* 隐藏默认三角形标记 */
}
summary::-webkit-details-marker {
display: none;
}
.arrow {
transition: transform 0.2s ease;
font-size: 14px;
color: #6b7280;
}
details[open] .arrow {
transform: rotate(90deg);
}
.answer {
padding: 0 0 16px 0;
color: #374151;
line-height: 1.6;
}
改造完成后,打开页面点击任何一个问题,动画和内容切换都很顺畅。更关键的是,整个交互不再依赖 JS 框架的状态管理,即使在浏览器禁用了脚本的情况下,手风琴依然可以正常工作——这对于内容型站点是一个很大的可访问性加分项。
四、动画平滑展开的技术细节
`details` 的默认展开行为是瞬间完成的,没有过渡动画。过去想给展开内容加高度过渡非常麻烦,因为 `details` 不具备 CSS 动画需要的已知高度。现在可以使用 `::details-content` 伪元素(较新的浏览器支持)或者通过 `interpolate-size` 属性来实现流畅的展开动画。
一个可靠的做法是使用 CSS 的 `@keyframes` 配合 `details[open]` 下的内容:
.answer {
overflow: hidden;
animation: fadeSlide 0.25s ease-out;
}
@keyframes fadeSlide {
from {
opacity: 0;
max-height: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
max-height: 500px; /* 足够大即可 */
transform: translateY(0);
}
}
这个动画在支持 `::details-content` 的浏览器里可以结合更精确的 `height` 过渡,但目前用 `max-height` 的降级方案依然能满足多数需求。随着浏览器对 `details` 动画支持的完善,这部分代码会越来越简洁。
五、可访问性方面的增强
原生 `details` 与 `summary` 组合有很好的内置可访问性。屏幕阅读器会正确播报折叠状态(展开或关闭),用户可以通过 Tab 键聚焦到 `summary`,按 Enter 或空格键切换。添加 `name` 属性后,这个互斥行为也被无障碍树正确反映——当用户展开一个新项目时,前一个项目的状态会更新,阅读器会播报变化。相比之下,手写的手风琴往往需要额外的 ARIA 属性标注,比如 `aria-expanded` 和 `role=”region”`,而原生方案自动包含了这些。
六、兼容性与回退策略
`
如果确实需要保证所有环境都能实现互斥,可以用 JavaScript 进行特性检测,仅在缺少原生支持时启用旧的脚本方案:
if (!('name' in document.createElement('details'))) {
// 启用旧的手风琴脚本
}
在实际的文档站点里,因为用户群体使用的设备都比较新,我没有加这个回退,直接用原生 `name` 上线,运行几个月以来没有收到任何兼容性 Bug 反馈。
七、总结
从几十行 JavaScript 的事件监听和状态切换,精简到一行 `name=”faq”`,这种体验就像当年从 `float` 布局切换到 `flexbox` 时那样豁然开朗。`details` 的 `name` 属性并不是什么花哨的新功能,它只是补上了互斥手风琴这件本该属于原生的拼图。对于内容类网站、文档站、帮助中心这类场景,它彻底解决了一个长期需要靠脚本弥补的缺口。
如果你正在维护一个带有FAQ或折叠面板的项目,并且还在用 `addEventListener` 写着那些老生常谈的手风琴逻辑,不如也去翻一下自己的代码,看看是不是可以删掉那堆 JS,把活交给浏览器本身。

