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

2026-09-20 0 696

给客服系统做帮助中心,产品要求是个 FAQ 手风琴,一次只开一条。按老习惯,第一反应是翻组件库或者写个百来行的 JS。但 HTML 里有个元素就是为这个场景生的,只是大部分人只用过它最基础的一面——点一下展开,再点一下收起。

细节比看起来多得多。这篇文章把 <details> 从最简形态一路讲到可动、可互斥、可访问,最后说清楚什么情况下还是得把它交给 JavaScript。

从最小形态说起:浏览器的三个默认行为

写一个能用的折叠面板,需要的 HTML 就这么点:

<details>
  <summary>如何修改绑定的手机号?</summary>
  <p>进入「账号安全」页面,点击手机号一栏右侧的修改按钮,验证原号码后即可更换。</p>
</details>

不加任何属性,浏览器就已经给了你三样东西:

  • 点击 summary 切换展开状态;
  • summary 天然处于 Tab 序列中,按 Enter 或 Space 可以切换,不需要写一行 JS 处理键盘事件;
  • 展开的内容会自动进入可访问性树,屏幕阅读器会读出「已折叠/已展开」的状态。

第三点尤其值得说。自己写 div 加 aria-expanded 是一回事,但真正让屏幕阅读器读对状态、读对按钮名字,往往要调半天。原生元素没有这个问题。

想默认展开,加 open 属性,语义上和 checkeddisabled 是同一类。

<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-faqsettings-billing

summary 的样式陷阱

想给 summary 加个小图标,或者把它做成 flex 布局让「标题 + 箭头」左右分开,会立刻撞上一个问题:三角形标记不见了。

默认的三角来自 list-style。一旦把 summarydisplay 改成 flexgrid 或者 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-size0 过渡到 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-rows0fr1fr 是可以插值的,这是近几年比较稳定的一个动画技巧。代价是需要在内容外面多包一层元素。

原生带来的两个免费好处

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,或者监听 summaryclick 并调用 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小时在线 专业服务