零 JavaScript 的丝滑手风琴
要做一个手风琴,大家都会习惯性地用上 React state 和一个测量高度的 hook。可浏览器早就内置了一个原生的——而且从 2024 年起,它已经能用纯 CSS 实现展开和收起的动画。本文讲透整套技法:details[name]、::details-content、interpolate-size,以及那个让收起也能有动画的关键属性。
手风琴是 Web 上被重复实现最多的组件之一。常见的做法是:用一份 React state 记录当前展开项,用一个 onClick 来切换,再配上一些 CSS(或一个测量高度的 hook)来做展开动画。它能用,但这会把一段静态内容变成一个客户端组件,为了开合而下发并 hydrate 一堆 JavaScript。
其实浏览器多年前就有了原生的手风琴元素:<details>。它唯一的历史短板——没法做动画——从 2024 年起已经不复存在。本文将构建一个完整带动画、互斥展开、带旋转箭头的手风琴,而且零 JavaScript:没有 state,没有 hook,也没有 hydration。
人人都在下发的那种手风琴
下面是它最常见的写法——用 React state 记录展开项,用一个点击处理函数来切换,再用 grid-template-rows: 0fr → 1fr 这个技巧,无需测量任何东西就能给高度做动画:
"use client"; // ← the whole component is now a client bundle
import { useState } from "react";
export function Accordion({ items }: { items: Item[] }) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<div>
{items.map((item) => {
const isOpen = openId === item.id;
return (
<div key={item.id}>
<button onClick={() => setOpenId(isOpen ? null : item.id)}>
{item.title}
</button>
{/* grid 0fr → 1fr is the CSS trick that animates the height */}
<div className="body" data-open={isOpen}>
<div>{item.content}</div>
</div>
</div>
);
})}
</div>
);
}.body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms;
}
.body[data-open="true"] { grid-template-rows: 1fr; }
.body > div { overflow: hidden; }这没什么问题,grid 技巧也确实巧妙。但注意它的代价:顶部那句 "use client"。这个组件现在会在它出现的每一个页面上被下载、解析并 hydrate——而这一切只是为了管理一个布尔值。对于一个本就不需要 React 意义上交互的展示型 FAQ 来说,这纯属额外开销。
你忘了的那个原生元素
<details> 和 <summary> 本身就是一个内置的展开/收起组件。开合状态、点击与键盘交互、无障碍支持,浏览器全都替你处理好了——免费:
<details>
<summary>What does it cost?</summary>
<p>Nothing — it's a native browser element.</p>
</details>这就是一个不需要任何 JavaScript、也不需要任何 CSS 的可用手风琴。点击 summary(或在其上按回车)它就会开合。多年来的症结在于它是瞬间开合的——没有办法给展开加动画,而这正是各团队转而选择 JavaScript 版本的原因。
为什么它一直没法做动画——直到最近
原因很微妙。当一个 <details> 处于收起状态时,浏览器并不只是把内容在视觉上隐藏起来——而是把内容完全移出渲染(一种类似 content-visibility 的隐藏方式)。你没法在“未渲染”这个状态之间做过渡,而且很长一段时间里,你同样没法让 height 在具体值和 auto 之间过渡。所以根本没有可供动画的两端。
有人尝试了 JavaScript 版本用的那个 grid 技巧,把它挪到 [open] 属性上:
/* Looks like it should work — but only animates OPENING. */
details > .body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms;
}
details[open] > .body { grid-template-rows: 1fr; }
/* On close, the browser hides the content the instant [open] is gone,
so the collapse never animates — it just snaps shut. */看起来没错,甚至连展开都能动起来。但它在收起时失效了:一旦 [open] 属性被移除,浏览器会立刻隐藏内容,收起动画根本没有机会播放——它只是啪地合上。一个只做了一半的方案,正是让所有人继续留在 JavaScript 上的原因。
解法:::details-content + interpolate-size
2024 年落地的三个 CSS 特性合力解决了这个问题。关键是一个新的伪元素 ::details-content,它精确命中 <details> 中可折叠的那部分——于是你可以直接对它设置样式并做动画:
:root {
/* lets height/block-size transition to and from the 'auto' keyword */
interpolate-size: allow-keywords;
}
details::details-content {
block-size: 0;
overflow: clip;
transition:
block-size 300ms ease,
/* keeps the content rendered while it collapses, instead of vanishing */
content-visibility 300ms allow-discrete;
}
details[open]::details-content {
block-size: auto;
}把它们当作一个整体来看,是三样东西在协同工作:
::details-content让你能够抓住那块隐藏的内容区域,而这是你以前根本无法选中的。interpolate-size: allow-keywords允许在0和固有的auto高度之间对block-size做动画——正是 CSS 长期以来拒绝做的事。transition-behavior: allow-discrete(通过把content-visibility列入 transition)让内容在整个收起过程中保持渲染,于是收起会有动画,而不是啪地合上。
三者合力,让两个方向——展开和收起——都能动起来,高度平滑地在内容的自然尺寸和零之间过渡。无需测量,无需 hook,也无需 state。
只用一个属性,实现互斥手风琴
大多数手风琴都想要“同时只展开一项”。在 JavaScript 版本里,这正是 openId state 的用途。而原生做法只需一个属性:给同组里每个 <details> 设置相同的 name,浏览器就会强制执行互斥——展开其中一个会收起其余的:
<!-- Same name = one exclusive group. Opening one closes the others. -->
<details name="faq"><summary>First</summary><p>…</p></details>
<details name="faq"><summary>Second</summary><p>…</p></details>
<details name="faq"><summary>Third</summary><p>…</p></details>有一点需要注意:name 定义的是横跨整个页面的互斥分组,所以如果你渲染了两个互不相关的手风琴,要给每一个都设置各自唯一的 name——否则你在其中一个里展开某项,会收起另一个里的某项。
不用 JavaScript 的箭头
人们最后一件会动用 JS 的事,是那个在展开时翻转的小箭头。它其实只是一个绑定在 [open] 属性上的 transform——属性由浏览器设置,所以这段旋转是纯 CSS。顺手也把默认的展开三角标记隐藏掉:
summary {
list-style: none; /* remove the default triangle marker */
cursor: pointer;
}
summary::-webkit-details-marker { display: none; } /* Safari */
summary .chevron {
transition: transform 200ms ease;
}
details[open] summary .chevron {
transform: rotate(180deg); /* flip on open — pure CSS, no JS */
}拼到一起
这就是完整的成品——一个互斥、带动画的手风琴项,配上旋转箭头,以及问与答之间的间距:
<details name="faq" class="item">
<summary>
<h3>How does it work?</h3>
<svg class="chevron" aria-hidden="true"><!-- ↓ --></svg>
</summary>
<div class="answer">
<p>Native open/close, exclusive grouping, smooth animation — zero JS.</p>
</div>
</details>:root { interpolate-size: allow-keywords; }
.item::details-content {
block-size: 0;
overflow: clip;
transition:
block-size 300ms ease,
content-visibility 300ms allow-discrete;
}
.item[open]::details-content { block-size: auto; }
.item[open] summary .chevron { transform: rotate(180deg); }
.answer { padding-top: 12px; } /* gap between question and answer */这就是完整的功能。开合、键盘支持、无障碍、互斥、平滑的双向动画,还有翻转的箭头——而它没有下发哪怕一个字节的 JavaScript。它在服务端渲染为静态 HTML,在客户端不需要任何东西就能工作。
浏览器支持
::details-content、interpolate-size 和 allow-discrete 都很新(Chromium 129–131、Safari 18.x,Firefox 也在跟进)。这听起来有风险,但它的降级方式是最好的那种——你根本不需要 @supports:
/* No @supports needed: browsers that don't know ::details-content simply
ignore these rules. The <details> still opens and closes — just instantly.
The animation is a progressive enhancement, never a requirement. */在一个不理解这些规则的浏览器里,<details> 依然能正确开合;只是会瞬间完成,没有动画。动画是叠加在一个本就到处都能用的元素之上的渐进增强——所以没有 fallback 需要写,也没有什么会坏掉。
开合、键盘、无障碍、互斥这些行为都是原生的,到处都能用。动画只是叠在上面的纯 CSS。如果某个浏览器太旧、跑不了动画,手风琴照样能用,只是不会有缓动而已。
CSS 与 JavaScript,说句实话
| 关注点 | JS 手风琴(state + hook) | 原生 <code><details></code> + CSS |
|---|---|---|
| 开 / 合 | React state + onClick | 浏览器内置 |
| 下发的 JavaScript | 有(客户端组件,需 hydrate) | 无 |
| 平滑动画 | 有(grid / 测量高度) | 有(::details-content) |
| 同时只开一项 | 手动 state(openId) | name 属性 |
| 键盘 + 无障碍 | 你自己接(或用库) | 原生 |
| hydrate 前 / 关掉 JS 时可用 | 否 | 是 |
| 箭头翻转 | 由 JS 切换 class | 在 [open] 上用 CSS |
当面板内容真正是动态的时候——按需加载、由应用状态驱动,或者展开必须触发你在 JS 里控制的副作用——JavaScript 依然更胜一筹。但对于最普遍的情形——一个展示型 FAQ 或展开列表——原生元素在功能上与 JS 版本一一对应,却没有那份 bundle 开销。
要点回顾
- 人人都在写的那种 JavaScript 手风琴,之所以存在,很大程度上是因为原生
<details>没法做动画——而这个限制现在已经没了。 ::details-content+interpolate-size: allow-keywords能让高度在0和auto之间做动画。- 让收起(而不只是展开)也能有动画的诀窍,是在
content-visibility上用transition-behavior: allow-discrete。 <details name="…">让你零 JavaScript 就能得到一个“同时只开一项”的互斥分组——每个手风琴用一个唯一的 name。- 箭头翻转就是一个绑定在
[open]属性上的 CSStransform。 - 它无需
@supports就能优雅降级:旧浏览器会瞬间开合,而动画只是纯粹的增强。
这些部件没有一个是稀奇的——<details> 已经存在了几十年,而那些动画特性也是无趣、规范定义清晰的 CSS。真正的转变,在于意识到你不再需要为了一个精致的手风琴而动用 React state。删掉那个客户端组件,下发静态 HTML,让浏览器去做它一直以来悄悄就能做到的事。