折叠面板这个组件,写了十年都还在写。它的技术难点从来没在样式上,就卡在一个地方:展开的时候内容高度是不定的,而 height: auto 不能做过渡。十年来大家各有各绕的方式,直到前两年规范上终于给了正经解法。
这篇把三条路线都摆出来对比,最后落到一个不写一行 JS 的手风琴实现上,代码可以直接复制进项目跑。中间那些文档里不会写的坑,也一并说了。
先说清楚问题出在哪
CSS 做过渡,要求起点和终点都是「可插值的值」。长度为 300px 到 500px,中间可以走 350、400、450,没问题。颜色从红到蓝,中间走一遍色相环,也没问题。
但 auto 是个关键字,它不是长度。浏览器在计算过渡曲线的时候,需要知道「从 0 走到 X」,这个 X 是多少。而对于 auto,浏览器得先把内容排版一遍才知道结果,这在过渡开始的那一刻是拿不到的。
同理的还有 fit-content、max-content、min-content,都属于「内在尺寸关键字」。过去这些年没少被它们坑。
两个老办法,各差一口气
max-height 猜一个大值
最常见的做法是给它一个明显偏大的 max-height,比如内容实际撑死 200px,你写 800px。
.panel {
max-height: 0;
overflow: hidden;
transition: max-height 320ms ease;
}
.panel.is-open {
max-height: 800px;
}
能跑,但动画节奏是错的。过渡是从 0 到 800 走完全程 320 毫秒,内容真正的高度可能只有 60px。也就是说,前 24 毫秒内容就已经完全展开了,后面 296 毫秒你什么都看不到,只是在等一个不会发生的动画结束。点开的时候像是「咔」一下弹出来的,收起的时候倒是能看见过程。
更难受的是,如果某个面板内容特别长、超过了 800px,它会被 overflow: hidden 永久截断。只能把估值往大了写,越写越大,动画就越不自然,成死循环。
grid-template-rows 从 0fr 走到 1fr
这个思路聪明得多。把内容装在一个 grid 容器里,只留一行,然后让这一行的轨道尺寸在 0fr 和 1fr 之间插值。
.wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 320ms ease;
}
.wrap.is-open {
grid-template-rows: 1fr;
}
.wrap > .inner {
min-height: 0;
overflow: hidden;
}
关键是内层那个 min-height: 0。grid item 的默认 min-height 是 auto,意味着它不会被压缩到内容尺寸以下。你只写 grid-template-rows: 0fr 的话,轨道确实收缩了,但内层元素死活撑在那儿,看起来就是完全没效果。
这套方案的动画节奏是对的,因为 fr 是从 0 到 1 线性走,实际像素高度是实时算出来的。代价是必须多包一层 DOM,而且内层那个 overflow: hidden 会把所有向外的阴影、focus 轮廓一起切掉。如果内容里有下拉菜单之类的浮层,也全废了。
正主出场:interpolate-size
规范里最终给的办法是加一条属性,声明「这个作用域内,允许内在尺寸关键字参与插值」。
:root {
interpolate-size: allow-keywords;
}
就这一行。之后 height: 0 到 height: auto 的过渡就能正常工作,浏览器会在过渡开始时先把 auto 算成一个具体像素值,然后按普通的长度插值走。
这个属性只有两个值:numeric-only(默认)和 allow-keywords。默认状态下关键字就是不可插值,跟以前一样,所以不开这条属性的话代码行为和过去完全一致。
它在根元素上设置会一路继承下去,全局生效。如果你担心影响到别处,也可以只写在某个容器的选择器上,作用域会收窄到那棵子树。
顺带说一个容易误会的点:开了这个属性不意味着所有东西都会自动开始动。它只是把「关键字不可插值」这个限制解除了,真的要有动画,还是得靠 transition 或者 animation 显式声明。所以全局开着这个值的风险,比想象中小得多。
calc-size() 是什么关系
如果不想全局开着 interpolate-size,可以用 calc-size() 做局部处理:
.panel.is-open {
height: calc-size(auto, size);
}
calc-size(auto, size) 的意思是:以 auto 为基础值,取它自身尺寸(size)作为结果。它本质上是把一个关键字显式地转换成了一个可参与运算的数值,所以能被过渡。
这个写法的好处是精度可控,比如 calc-size(auto, size + 8px) 在内容高度上再加 8px 内边距。缺点是得在每一个 height: auto 出现的地方都改一遍,样本一多就啰嗦。我现在基本是全局开 interpolate-size,只在极少数需要额外加减的地方用 calc-size()。
用 details 搭一个真正的手风琴
光有过渡还不够,展开收起的状态管理也得有人管。过去是用 <details> 加 JS 监听 toggle 事件,或者干脆自己用按钮加 aria-expanded 手搓无障碍属性。
现在 <details> 本身的动画能力补上来了,可以从头到尾不写 JS。
HTML 结构
<div class="faq">
<details name="faq" open>
<summary>发票能否开具公司抬头?</summary>
<div class="body">
<p>可以。下单时在备注里填写公司全称和税号,订单完成后三个工作日内会把电子发票发到注册邮箱。</p>
<p>个人抬头不影响后续补开,但需要联系客服提交营业执照副本。</p>
</div>
</details>
<details name="faq">
<summary>换货的运费谁承担?</summary>
<div class="body">
<p>质量问题的往返运费由平台承担;非质量问题的换货运费由买家自理,具体金额在下单页会显示。</p>
</div>
</details>
<details name="faq">
<summary>订单可以修改收货地址吗?</summary>
<div class="body">
<p>未发货前可以修改,进入订单详情点击「修改地址」即可。已发货的订单需要联系快递公司拦截,成功率视物流状态而定。</p>
</div>
</details>
</div>
三个 details 都写了同一个 name="faq",这几个就自动互斥了——点开一个,另外几个会自动合上。这个属性是原生手风琴的关键,以前得靠 JS 遍历兄弟节点手动关闭。
注意 summary 必须是 details 的第一个子元素,位置不能挪。
先处理掉默认样式
summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
padding: 18px 20px;
font-weight: 600;
user-select: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::marker {
content: "";
}
Chrome 和 Safari 需要在 ::-webkit-details-marker 上关掉三角标,Firefox 走的是 ::marker,两边都写一遍最稳。list-style: none 单独写在某些版本上对 summary 无效,别看它生效就不写伪元素那两条。
箭头翻转
summary::after {
content: "";
width: 8px;
height: 8px;
margin-left: auto;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
rotate: 45deg;
transition: rotate 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
details[open] summary::after {
rotate: -135deg;
}
用两条边框拼箭头,比塞一个 SVG 省事,而且颜色跟着 currentColor 走,暗色模式不用额外处理。rotate 那条曲线的第三个参数给了点回弹,展开的时候箭头有个轻微的过冲,比线性转过去显得有精神。
核心:让内容真正长出来
:root {
interpolate-size: allow-keywords;
}
details::details-content {
content-visibility: hidden;
block-size: 0;
overflow: clip;
transition:
block-size 340ms cubic-bezier(0.2, 0.8, 0.2, 1),
content-visibility 340ms;
transition-behavior: allow-discrete;
}
details[open]::details-content {
content-visibility: visible;
block-size: auto;
}
这四行里每一行都有讲究,一个一个说。
::details-content 是个伪元素,代表 details 里除去 summary 之外的全部内容。以前想给这块内容加动画,只能自己再包一层 div,现在可以直接选它了。
content-visibility: hidden 是关闭状态下这块内容应有的状态。浏览器自己也是这么干的,但我们必须显式写出来才能对它做过渡——因为要过渡的属性得先在样式规则里明确出现。
block-size: 0 到 auto 这一段,靠的就是上面开的 interpolate-size。这里用逻辑属性 block-size 而不是 height,是为了在书写方向变化时行为正确,两者在这段场景下效果一样。
最关键的是 transition-behavior: allow-discrete。像 content-visibility 这样的属性值是「离散」的——它没有中间态,要么 visible 要么 hidden。CSS 的默认规则是离散属性不做过渡,值直接在过渡开始的瞬间切换。加上 allow-discrete 之后,浏览器会等整段过渡时间走完,再把值切过去。
如果漏掉这一行,会看到什么?打开的一瞬间内容就已经 visible 了,但高度还卡在 0,于是内容先撑爆容器再被裁回去,视觉上就是一次闪跳。这个现象在小尺寸下不明显,内容一多就很刺眼。
overflow 为什么用 clip 而不是 hidden
这是我自己被坑过的地方。overflow: hidden 会把这个元素变成一个滚动容器,虽然你看不到滚动条,但浏览器内部确实建了一个。滚动容器会带来两个副作用:一是内部的 position: sticky 会失效,二是滚动链传导行为会变,在某些移动端浏览器上会额外触发一次布局。
overflow: clip 只做裁剪,不建滚动容器,语义上也更贴合「我只是不想让超出的内容露出来」。目前现代浏览器都已经支持了,如果项目必须兼容很老的机型,可以前面补一行 overflow: hidden 作为回退。
补上首次渲染
目前的实现还有个遗漏:页面加载时,那个带 open 的第一个面板,在需要重排时不会有任何过渡。如果它原本内容就是展开的,那本来也不需要动画,倒也无所谓。
但如果页面里还有弹层、提示条这类「动态插入 DOM 后立刻显示」的元素,就需要 @starting-style 了。它的作用是给元素第一次渲染之前提供一个起始样式:
@starting-style {
.toast {
opacity: 0;
translate: 0 16px;
}
}
.toast {
opacity: 1;
translate: 0 0;
transition: opacity 240ms ease, translate 240ms ease;
}
元素从 display: none 变到可见、或者第一次挂载进 DOM 的时候,浏览器会先套一遍 @starting-style 里的值作为起始点,然后按正常的过渡走。没有这个规则的话,元素直接就「啪」地出现在终点了。
注意 @starting-style 里的选择器,优先级和外面一样,所以顺序上有讲究——把 @starting-style 写在前面,正常样式写在后面,或者至少让正常样式的选择器不弱于它。
踩过的坑,按痛苦程度排
聚焦轮廓被裁掉
overflow: clip 加在 ::details-content 上,意味着里面所有超出这块区域的东西都被切了。如果内容里有输入框,键盘 Tab 过去时 outline 往外扩的那一圈会被切掉一半,看起来很脏。
做法是给内容自己留出内边距,让 focus ring 有地方画:
.body {
padding: 0 20px 20px;
}
.body :focus-visible {
outline-offset: 2px;
}
另外,如果内层还有卡片式的带阴影元素,阴影同样会被裁。这时候要么接受裁切(阴影范围小一点),要么把这个组件改成不依赖外部溢出(例如给卡片改内描边)。
关闭状态下内容仍被 Tab 走到
这里有个容易被忽略的细节。content-visibility: hidden 会让元素在可访问性树和 Tab 顺序里都消失,这正是我们想要的。但如果只在过渡中临时把它改成了 visible,过渡结束后没切回去,内容虽然看不见(高度为 0),但依然能被 Tab 聚焦到,用户焦点会莫名其妙跳到屏幕外面。
上面那段 CSS 的写法没有这个问题,因为 content-visibility 的最终值是由 [open] 状态决定的,跟过渡进度无关。allow-discrete 只影响它什么时候切换,不影响它最终是哪个值。所以这段是可以放心的。
互斥展开会有瞬变吗
点开第二个面板的时候,第一个面板同时开始收起,两块动画一起跑。视觉上是正常的,因为两边的过渡时长和曲线一致。如果两侧时间和曲线不一样,就会出现一快一慢、页面高度来回抖一下的感觉。
所以三个面板的 transition 参数最好抽到一个公共变量里:
:root {
--panel-transition: 340ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
details::details-content {
transition:
block-size var(--panel-transition),
content-visibility var(--panel-transition);
}
尊重一下系统设置
@media (prefers-reduced-motion: reduce) {
details::details-content,
summary::after {
transition-duration: 1ms;
}
}
苹果那边「减弱动态效果」打开之后,系统级动画都会收敛。自己的组件里也跟上,成本就几行。注意这里用的是 1ms 而不是 0s——0s 在某些实现下会直接被跳过、连 transitionend 都不触发,如果哪天要在事件里挂东西就会出问题。
兜底方案怎么写
interpolate-size 目前主要在 Chromium 系里落地,Safari 和 Firefox 还在推进中。所以不做兜底的话,那两家用户看到的效果是:点击展开,内容瞬间出现,没有动画。功能是可用的,只是不好看。
这个降级其实可以接受。但有一个状态必须处理:如果浏览器支持 ::details-content 但不支持 interpolate-size(理论上少见,但版本迭代期间可能遇到),block-size: 0 就会生效而 auto 也生效,只是不能插值——结果就是展开时没动画,但状态是对的,不闪不跳。安全。
真正需要兜底的是另一种情况:某些老版本浏览器根本不认识 ::details-content 这个伪元素。不认识的话整条规则直接被丢弃,details 走默认行为,内容该显示显示该隐藏隐藏,一样能用。所以不用写任何 @supports 兜底。
如果硬要在意那个「无动画」的落差,可以加一段:
@supports not (interpolate-size: allow-keywords) {
details::details-content {
transition: none;
}
}
把过渡明确关掉,反而比让浏览器用一个错误的时长去尝试要干净。我倾向加上这段。
完整代码
把上面所有片段拼起来,就是一个能直接扔进项目的文件:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>常见问题</title>
<style>
:root {
interpolate-size: allow-keywords;
--panel-duration: 340ms;
--panel-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 32px 16px;
font: 16px/1.7 -apple-system, "Segoe UI", "PingFang SC", sans-serif;
color: #1f2328;
background: #f6f7f9;
}
.faq {
max-width: 620px;
margin: 0 auto;
}
.faq details {
background: #fff;
border: 1px solid #e4e6eb;
border-radius: 12px;
margin-bottom: 12px;
overflow: clip;
}
summary {
list-style: none;
display: flex;
align-items: center;
gap: 12px;
padding: 18px 20px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
summary::-webkit-details-marker { display: none; }
summary::marker { content: ""; }
summary::after {
content: "";
width: 8px;
height: 8px;
margin-left: auto;
flex: none;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
rotate: 45deg;
transition: rotate 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
details[open] summary::after {
rotate: -135deg;
}
details::details-content {
content-visibility: hidden;
block-size: 0;
overflow: clip;
transition:
block-size var(--panel-duration) var(--panel-ease),
content-visibility var(--panel-duration);
transition-behavior: allow-discrete;
}
details[open]::details-content {
content-visibility: visible;
block-size: auto;
}
.body {
padding: 0 20px 20px;
color: #4a5056;
}
.body p { margin: 0 0 10px; }
.body p:last-child { margin-bottom: 0; }
@supports not (interpolate-size: allow-keywords) {
details::details-content { transition: none; }
}
@media (prefers-reduced-motion: reduce) {
details::details-content,
summary::after {
transition-duration: 1ms;
}
}
</style>
</head>
<body>
<div class="faq">
<details name="faq" open>
<summary>发票能否开具公司抬头?</summary>
<div class="body">
<p>可以。下单时在备注里填写公司全称和税号,订单完成后三个工作日内会把电子发票发到注册邮箱。</p>
<p>个人抬头不影响后续补开,但需要联系客服提交营业执照副本。</p>
</div>
</details>
<details name="faq">
<summary>换货的运费谁承担?</summary>
<div class="body">
<p>质量问题的往返运费由平台承担;非质量问题的换货运费由买家自理,具体金额在下单页会显示。</p>
</div>
</details>
<details name="faq">
<summary>订单可以修改收货地址吗?</summary>
<div class="body">
<p>未发货前可以修改,进入订单详情点击「修改地址」即可。已发货的订单需要联系快递公司拦截,成功率视物流状态而定。</p>
</div>
</details>
</div>
</body>
</html>
最后说两句
这套组合的价值不只是少写了 JS。原生 details 自带键盘操作、屏幕阅读器兼容、以及浏览器内置的查找功能(Ctrl+F 能直接搜到折叠内容并自动展开)。自己用按钮加 div 手搓,这些都要补一大圈 aria-* 属性,还未必补得全。
真正需要注意的是那些不支持 interpolate-size 的浏览器。我的做法是:功能上完全依赖原生 details,动画上尽力而为,掉了也没关系。这比为了统一动画效果装一个几百 KB 的组件库划算太多。
哪天你想给折叠面板加个「展开时懒加载内容」的逻辑,可以监听 beforetoggle 和 toggle 事件,那就又是另一篇文章了。

