给客服系统做帮助中心,产品要求是个 FAQ 手风琴,一次只开一条。按老习惯,第一反应是翻组件库或者写个百来行的 JS。但 HTML 里有个元素就是为这个场景生的,只是大部分人只用过它最基础的一面——点一下展开,再点一下收起。
细节比看起来多得多。这篇文章把 <details> 从最简形态一路讲到可动、可互斥、可访问,最后说清楚什么情况下还是得把它交给 JavaScript。
从最小形态说起:浏览器的三个默认行为
写一个能用的折叠面板,需要的 HTML 就这么点:
<details>
<summary>如何修改绑定的手机号?</summary>
<p>进入「账号安全」页面,点击手机号一栏右侧的修改按钮,验证原号码后即可更换。</p>
</details>
不加任何属性,浏览器就已经给了你三样东西:
- 点击
summary切换展开状态; summary天然处于 Tab 序列中,按 Enter 或 Space 可以切换,不需要写一行 JS 处理键盘事件;- 展开的内容会自动进入可访问性树,屏幕阅读器会读出「已折叠/已展开」的状态。
第三点尤其值得说。自己写 div 加 aria-expanded 是一回事,但真正让屏幕阅读器读对状态、读对按钮名字,往往要调半天。原生元素没有这个问题。
想默认展开,加 open 属性,语义上和 checked、disabled 是同一类。
<details open>
<summary>配送范围</summary>
<p>目前支持全国除港澳台以外的地区。</p>
</details>
脚本里想读状态或者改状态也很直接:
const el = document.querySelector('details');
console.log(el.open); // true 或 false
el.open = false; // 收起,会触发 toggle 事件
el.addEventListener('toggle', () => {
console.log('状态变了,现在是', el.open);
});
toggle 事件在状态变化之后才派发,不像一些组件库的 beforeChange 那样能拦截。需要拦截的场景,后面第七部分再说。
name 属性:一次只开一条
手风琴的老规矩是一次只开一条。这件事以前必须靠 JS 手动维护,现在浏览器自己会管:
<details name="faq">
<summary>怎么开发票?</summary>
<p>订单完成后在「我的订单」里选择开票,电子发票会在 24 小时内发送到邮箱。</p>
</details>
<details name="faq">
<summary>发票信息填错了怎么办?</summary>
<p>未开具的发票可以随时修改;已开具的需要申请红冲后重新开具。</p>
</details>
<details name="faq">
<summary>能开公司抬头的发票吗?</summary>
<p>可以,在下单时填写公司名称和税号即可。</p>
</details>
name 相同的 details 会被浏览器视为一组,展开其中一个,其余自动收起。这个行为和单选按钮的 name 分组是一个思路。
几个规则要记牢:
- 同组最多只有一个展开。如果 HTML 里给多个都写了
open,浏览器会保留最后一个,其余的open会被自动移除。 - 允许一个都不展开。用户可以点开当前的把整组收起,没有「必须选一个」的限制,这点和
radio不一样。 name为空字符串,或者省略这个属性,就不参与互斥,等同于旧行为。- 命名空间是文档级的,不同模块的
name不要撞车,建议加上前缀,比如help-faq、settings-billing。
summary 的样式陷阱
想给 summary 加个小图标,或者把它做成 flex 布局让「标题 + 箭头」左右分开,会立刻撞上一个问题:三角形标记不见了。
默认的三角来自 list-style。一旦把 summary 的 display 改成 flex、grid 或者 inline-flex,就等于把它从 list-item 变回了普通块,标记随之消失。
.faq summary {
display: flex;
justify-content: space-between;
align-items: center;
}
有时候这是你想要的——比如你想用自己的箭头图标。但更多时候人会懵一下「三角去哪了」,其实是被自己的样式吃掉了。
想改三角的颜色,正确姿势是 ::marker:
.faq summary::marker {
color: #c0392b;
font-size: 0.8em;
}
summary 上的 ::marker 只支持一小部分属性,颜色、字号、内容。想换成一个自定义箭头,就得先关掉原生标记,再用伪元素画一个:
.faq summary {
list-style: none;
}
.faq summary::-webkit-details-marker {
display: none;
}
.faq summary::after {
content: "▸";
transition: transform 0.2s ease;
}
.faq details[open] summary::after {
transform: rotate(90deg);
}
那个 ::-webkit-details-marker 是为老 Safari 准备的,现代 Safari 已经跟上了 list-style: none,但清一下没有坏处。
还有个小坑:summary 必须是 details 的第一个子元素。写到第二个位置,浏览器就不认它是标题,会把它当成普通内容隐藏起来,details 就再也没有可见的切换按钮了。这个错误不太容易自查,因为页面上不会报错,只是那块内容点不开。
用 ::details-content 做展开动画
原生 details 展开是瞬时的,没有过渡。这部分以前只能用 JS 配合 max-height 或者 grid-template-rows 硬做,现在有了新伪元素 ::details-content,可以直接在样式里处理。
先看一个能跑的版本:
:root {
interpolate-size: allow-keywords;
}
details::details-content {
block-size: 0;
overflow: hidden;
transition: block-size 0.35s ease, content-visibility 0.35s;
transition-behavior: allow-discrete;
}
details[open]::details-content {
block-size: auto;
}
其中三段话解释一下:
::details-content 指向的是 details 里除 summary 之外的那部分内容。以前这块内容在 DOM 上不可寻址,现在能单独给它写样式了。
content-visibility 用来控制内容的可见性。展开的时候它从 hidden 变成 visible,这个属性默认不支持过渡,所以要配 transition-behavior: allow-discrete,让它在离散变化的场景下也能参与动画的起止点计算。
interpolate-size: allow-keywords 是关键的一行。默认情况下 block-size 从 0 过渡到 auto 是没法插值的,浏览器不知道该动到多高。开启这个属性后,auto 会被当成可插值的值处理,动画才能跑起来。
如果目标浏览器还不支持 interpolate-size,可以退回到 grid 方案,兼容性更好但需要包一层:
<details>
<summary>退换货政策</summary>
<div class="panel">
<p>签收后 7 天内可无理由退货,商品需保持完好……</p>
</div>
</details>
.panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.35s ease;
}
.panel > * {
overflow: hidden;
}
details[open] .panel {
grid-template-rows: 1fr;
}
grid-template-rows 从 0fr 到 1fr 是可以插值的,这是近几年比较稳定的一个动画技巧。代价是需要在内容外面多包一层元素。
原生带来的两个免费好处
用 details 有一个用 JS 写很难复刻的体验:Ctrl+F 查找时,如果匹配的内容藏在折叠面板里,浏览器会自动把它展开,让用户看到高亮结果。
自研组件要做这件事,得监听键盘、循环遍历所有节点、匹配文本、找到祖先 details 并展开——完整的实现相当难写。用原生元素,这个能力是免费的。
第二个是打印。展开状态会带进打印视图,而折叠状态打印时显示成标题行,这个行为挺合理:
@media print {
details {
page-break-inside: avoid;
}
}
加上这一行,避免一个问答被拆到两页。
可访问性:两个常被忽略的细节
summary 里不要放交互元素。summary 本身是一个可点击、可聚焦的控件,如果在里面再塞一个 <a> 或者 <button>,就会出现「点击箭头展开、点击链接跳转」的冲突,屏幕阅读器读出来的东西也会变得难以理解。
需要「展开后里面有个操作按钮」,把按钮放到内容区,而不是 summary 里。
summary 的文字本身就是这个折叠块的「名字」,屏幕阅读器会把 summary 的文本内容拿来当按钮标签。所以不要写成「点击查看」这种没有信息的字样,标题要说清楚里面是什么内容。
<!-- 不推荐 -->
<summary>点此查看详情</summary>
<!-- 推荐 -->
<summary>如何修改绑定手机号</summary>
另外,如果某个折叠块默认就应该是展开的,用 open 属性而不是在 JS 里手动设置,这样首屏渲染和可访问性树构建的时候状态就是对的,不会有闪一下再展开的观感。
完整案例:帮助中心 FAQ 面板
把上面这些东西拼起来,就是一个可以直接用的 FAQ 组件。
<section class="faq">
<h2>订单与发票</h2>
<details name="help-order">
<summary>订单提交后还能修改收货地址吗?</summary>
<p>未发货前可以联系客服修改。已发货的订单需要联系快递拦截,可能产生额外费用。</p>
</details>
<details name="help-order">
<summary>怎么申请开具电子发票?</summary>
<p>订单完成后进入「我的订单」页面,找到对应订单,点击「申请开票」,填写抬头信息后提交即可。</p>
<p>电子发票一般在 24 小时内发送到预留邮箱。</p>
</details>
<details name="help-order">
<summary>发票抬头填错了怎么办?</summary>
<p>未开具的发票可以随时修改;已开具的需要申请红冲,重新开具后旧发票作废。</p>
</details>
</section>
配套样式(这段代码放在你自己的样式表里):
:root {
interpolate-size: allow-keywords;
}
.faq details {
border-bottom: 1px solid #e5e5e5;
padding: 4px 0;
}
.faq summary {
padding: 12px 4px;
cursor: pointer;
font-weight: 500;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.faq summary::-webkit-details-marker {
display: none;
}
.faq summary::after {
content: "▸";
transition: transform 0.2s ease;
}
.faq details[open] summary::after {
transform: rotate(90deg);
}
.faq details::details-content {
block-size: 0;
overflow: hidden;
transition: block-size 0.3s ease, content-visibility 0.3s;
transition-behavior: allow-discrete;
}
.faq details[open]::details-content {
block-size: auto;
}
.faq details p {
margin: 0 0 12px;
padding: 0 4px;
color: #444;
line-height: 1.7;
}
整块代码里没有一行 JavaScript,但用户拿到的是:键盘可操作、屏幕阅读器可读、一次只展开一条、有平滑动画、Ctrl+F 能自动展开命中项。
什么时候还是得让 JS 上场
原生能力很强,但有些场景它确实覆盖不到位,这时候不要硬扛。
需要记住用户上次展开的内容。比如用户刷新页面后希望恢复到上次看的那个问题。原生 open 不会持久化,得写一小段脚本配合 localStorage,并且在 toggle 事件里更新存储内容。
展开前要拦截。比如「未登录用户点击展开时先弹出登录框」。原生的 toggle 事件在状态变化之后才派发,没法阻止。这种情况要用按钮代替 summary,或者监听 summary 的 click 并调用 preventDefault。
展开后需要异步加载内容。折叠块里是懒加载的接口数据时,需要监听 toggle、判断是否首次展开、发起请求、把结果插到内容区里。这个逻辑没法纯用 HTML 表达。
需要精细的动画时序。比如展开的时候内容是淡入加位移,收起的时候是快速隐去。纯 CSS 能做大部分,但复杂时序(展开动画没结束就点收起、中途打断)还是得交给 JS 去协调。
除此之外,绝大多数 FAQ 页面、设置面板、帮助文档的折叠区,原生 details 加上 name 属性已经完全够用。
收尾
HTML 里有一批元素是「看起来简单、用起来才知道细节多」。details 就是其中一个。它把键盘交互、可访问性语义、Ctrl+F 自动展开这些事都包在浏览器里了,自己写一遍很难做到同样完善。
真正值得花时间的地方不在「要不要用」,而在用的时候把 name 分组、::details-content 动画、summary 的样式边界这几处搞清楚。这几个地方踩过一遍之后,下次做手风琴大概就不会再想去找组件库了。

