CSS 原生手风琴实战:让 height: auto 真正能过渡的完整写法

2026-10-11 0 171

折叠面板这个组件,写了十年都还在写。它的技术难点从来没在样式上,就卡在一个地方:展开的时候内容高度是不定的,而 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 事件,那就又是另一篇文章了。

CSS 原生手风琴实战:让 height: auto 真正能过渡的完整写法
收藏 (0) 打赏

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

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

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

淘吗网 css CSS 原生手风琴实战:让 height: auto 真正能过渡的完整写法 https://www.taomawang.com/web/css/2923.html

常见问题

相关文章

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

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