details 元素进阶实战:不用 JavaScript 写一个能动画的手风琴

2026-09-20 0 752

给客服系统做帮助中心,产品要求是个 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 的样式边界这几处搞清楚。这几个地方踩过一遍之后,下次做手风琴大概就不会再想去找组件库了。

details 元素进阶实战:不用 JavaScript 写一个能动画的手风琴
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html details 元素进阶实战:不用 JavaScript 写一个能动画的手风琴 https://www.taomawang.com/web/html/2793.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务