做 FAQ 页面、做设置面板、做文档侧边导航的时候,”手风琴”这个组件几乎每次都会碰到。它的交互规则很明确:点开一个,原本展开的那个自动收起来。听起来简单,但用 <details> 硬做的话,得写一段监听 toggle 事件的脚本,把其他打开的 <details> 挨个 removeAttribute('open'),还要小心不要触发循环。
这段代码不难写,但它属于那种”明明应该是 HTML 层的事,却偏要用 JS 兜”的负担。而 name 属性就是来终结它的。
两行 HTML,一个互斥手风琴
先看最小可用的例子:
<details name="faq">
<summary>支持哪些支付方式?</summary>
<p>微信、支付宝、银联以及货到付款。</p>
</details>
<details name="faq">
<summary>多久能发货?</summary>
<p>工作日 16:00 前下单当天发出。</p>
</details>
<details name="faq">
<summary>支持七天无理由吗?</summary>
<p>支持,非定制品类都可以。</p>
</details>
打开页面就能用。点第二个的时候,第一个会自动收起来。没有一行 JavaScript。
这里的规则很简单:给一组 <details> 起一个相同的 name,它们就会被浏览器视为”同一组”,同一时刻最多只有一个可以处于展开状态。名字用什么字符串无所谓,只要同一个页面里同一组元素用的一致就行。
它和 radio 的思路很像,但有个关键区别
如果你写过表单,会发现这个机制跟单选按钮的 <input type="radio" name="..."> 特别像。给一组 radio 一个相同的 name,它们就自动互斥。浏览器在更底层的”命名分组”逻辑上是一套东西。
但有一个差异要记住:radio 一旦选中就没法取消,<details> 可以全部收起来。
如果你想要”必须有一项展开”的效果,比如 tab 面板那样,得自己监听 toggle 事件:
const group = document.querySelectorAll('details[name="tabs"]');
group.forEach(el => {
el.addEventListener('toggle', () => {
if (!el.open) {
const anyOpen = [...group].some(d => d.open);
if (!anyOpen) {
// 全部被关掉了,把刚关的那个重新打开
el.open = true;
}
}
});
});
这种写法小心中间会有一次视觉抖动。toggle 事件是在状态已经变化之后触发的,所以用户会看到”收起来又立刻展开”的闪一下。更稳的做法是用 beforetoggle,它是变化前触发,可以 preventDefault() 阻止关闭:
group.forEach(el => {
el.addEventListener('beforetoggle', e => {
if (e.newState === 'closed') {
const anotherOpen = [...group].some(
d => d !== el && d.open
);
if (!anotherOpen) e.preventDefault();
}
});
});
不过大多数场景下,允许全部收起其实更舒服。用户点开看完了想全收起来,你还拦着不让关,是反常识的。
加开合动画:先把默认行为搞清楚
直接给 details 加 transition 是没用的,因为它在关闭时 content 部分会被设成 display: none,没有中间状态可过渡。
以前要做动画,得自己包一层 wrapper,用 JS 读取 scrollHeight,设置 height: 0 再改成具体像素值,动画结束再改成 auto。这套流程写过的人都知道有多烦。
现在浏览器提供了 ::details-content 伪元素,它代表的是 <details> 里除了 <summary> 之外的那部分内容。给它加过渡就行:
details::details-content {
block-size: 0;
overflow: hidden;
transition:
block-size 280ms ease,
content-visibility 280ms allow-discrete;
}
details[open]::details-content {
block-size: auto;
}
关键在 allow-discrete 上。它让 content-visibility 这种离散属性也能参与过渡——浏览器会等到 block-size 动画播完,才真正把内容设为不可见。如果不加这一句,内容会在动画刚开始的瞬间就消失。
还有一个细节是 block-size: auto。CSS 里 auto 到具体数值本来是没法插值的,这么多年一直是动画做不了灵活高度的老大难。Chrome 从 129 开始支持了 interpolate-size: allow-keywords,写了它之后,浏览器就会用 calc-size 的方式给 auto 一个计算出来的值,然后做插值。
写法是在根元素上加一行:
:root {
interpolate-size: allow-keywords;
}
有了这一行,前面那段 block-size: auto 的动画才能真正跑起来。不加的话动画会直接跳到终点,中间没有插值过程。
完整案例:一个产品 FAQ 页面
把上面的东西拼起来,做一个带点样子的 FAQ 区域。
<section class="faq">
<h2>常见问题</h2>
<details name="faq">
<summary>支持哪些支付方式?</summary>
<p>微信支付、支付宝、银联,企业客户可以通过对公转账。</p>
</details>
<details name="faq">
<summary>订单多久发货?</summary>
<p>工作日 16:00 前下单当天发出,其余次日发出。节假日顺延。</p>
<p>偏远地区可能额外需要 1 到 2 天。</p>
</details>
<details name="faq">
<summary>支持七天无理由吗?</summary>
<p>支持。非定制品类、不影响二次销售的情况下都可以。</p>
</details>
<details name="faq">
<summary>发票怎么开?</summary>
<p>下单时勾选开票,电子发票会在发货后 24 小时内发到邮箱。</p>
</details>
</section>
样式部分(省略视觉细节,只保留和交互相关的):
:root {
interpolate-size: allow-keywords;
}
details {
border-block-end: 1px solid #e6e6e6;
}
summary {
cursor: pointer;
padding: 14px 0;
list-style: none;
position: relative;
}
summary::-webkit-details-marker {
display: none;
}
summary::after {
content: "+";
position: absolute;
right: 0;
transition: transform 220ms ease;
}
details[open] summary::after {
transform: rotate(45deg);
}
details::details-content {
block-size: 0;
overflow: hidden;
transition:
block-size 280ms ease,
content-visibility 280ms allow-discrete;
}
details[open]::details-content {
block-size: auto;
}
这里面有几处值得展开。
list-style: none 和 ::-webkit-details-marker。 这两行是为了干掉浏览器默认那个三角形。Chrome、Safari 用 ::-webkit-details-marker,Firefox 用 list-style,两个都写才能覆盖干净。
summary::after 做加号。 转 45 度就成了叉,语义上比三角更直观——展开代表”可以关掉了”。这个动画跟 details-content 的时长配一下会更协调,我这里都用了 220 到 280 毫秒之间,看起来比较统一。
不要给 summary 设 display: flex。 这个坑遇到过一次。设了之后,Safari 会丢失点击区域的语义,某些情况下点击不响应。用绝对定位的伪元素更保险。
适合 FAQ,也适合做设置面板
手风琴不只在 FAQ 上有用,设置页也经常需要这种结构。比如:”账户安全”、”通知偏好”、”隐私设置”三个分组,同一时刻只展开一个,视觉上清爽。
<details name="settings">
<summary>账户安全</summary>
<label>修改密码</label>
<label>绑定手机</label>
<label>二次验证</label>
</details>
<details name="settings">
<summary>通知偏好</summary>
<label><input type="checkbox">短信通知</label>
<label><input type="checkbox">邮件通知</label>
</details>
这种情况下 name 的作用就更明显了——用户点开一个分组去改设置,另一个自动收起来,不用手动去关。而且关闭的动画和打开的动画是同步执行的,视觉上很舒服。
我一般会在这个场景下补一句 details:first-of-type 让它默认展开,毕竟让用户看到空白的三个标题有点别扭。
几个踩过的坑
同一组 name 不能跨上下文复用。 这点文档里没写得很显眼,但浏览器确实按”表单树”来划分——如果你把一组 <details name="x"> 放在一个 <form> 里,另一组放在 form 外面,它们其实不会互斥,即使 name 一模一样。想要互斥就往同一个父级下放。
动态改变 name 会反应得很奇怪。 如果你用 JS 改 details.name,浏览器不会立即重新分组,需要整个 DOM 重新计算一次。生产环境的做法是:用 removeAttribute('name') 把元素从组里摘出来,改完结构之后再重新 setAttribute('name', ...)。或者干脆不要动 name,改用 CSS 控制可见性来规避。
iOS Safari 支持得晚一点。 details name 大概在 Safari 17.2 才开始支持(2023 年底),跨文档到 2024 年才逐渐铺开。::details-content 和 interpolate-size 更晚,Safari 26(2025 年)才补上。如果你的产品还有相当一部分旧设备用户,做好降级——不支持的时候浏览器会表现得像没有 name,所有 <details> 都能同时打开。功能不丢,只是体验差一点。
打印样式记得处理。 FAQ 页有时候会被用户打印下来,默认情况下 details 没展开的内容是不会打印的。加一段 @media print { details { display: block; } details::details-content { block-size: auto; } } 就能把全部内容打出来。这个在给客户的 PDF 里经常需要。
不要给 <details> 套一个 <dialog>。 这两个东西的语义有点冲突,一个表示”可折叠的摘要内容”,另一个表示”模态窗口”。硬套会带来辅助技术识别混乱。
什么时候不该用 details 手风琴
虽然这套东西很好用,但也不是所有折叠场景都能上。
如果你的内容需要保持 DOM 存在(比如图表要持续更新、视频要一直播放),用 <details> 就不合适,因为在关闭之后 content-visibility 会把内容设为不可见,一些性能敏感的逻辑会受影响。
另外 <details> 天生是”块级”的语义,如果你要做的是横向的 tab 面板切换,用 <details> 是有点勉强的。虽然加 CSS 也能做出来,但辅助技术读起来会觉得怪。这种情况用 ARIA 的 tablist 更合适。
至于嵌套的 <details>,现在浏览器也支持了,但你要留意一下里面的 details 是不是也该参与同一个 name。默认是隔离的,外层打开不会影响内层,这是对的行为。
收尾
过去这些年,很多”交互组件”都默认是要用 JS 写的。手风琴、tab、模态框、弹出层,每一个都得写一段监听逻辑、管理一堆状态。但浏览器近几年的动作很明确——把这些通用的、模式固定的交互,收回到 HTML 和 CSS 的原生能力里。
<details name> 是这套动作里比较小而美的一块。写一个 FAQ 页面,两行 HTML 加一段动画 CSS,一百行 JS 就这么省掉了,而且行为更接近浏览器的”确定行为”——不会因为你的状态管理写得有 bug 而漏掉某个边界情况。
如果手上正好有几个用 JS 手写的手风琴组件,可以考虑改一份试试。改完之后回过头看,会有点”以前怎么忍的”那种感觉。

