Skip to main content
返回博客
CSSHTMLAccordionFrontendPerformance

零 JavaScript 的丝滑手风琴

要做一个手风琴,大家都会习惯性地用上 React state 和一个测量高度的 hook。可浏览器早就内置了一个原生的——而且从 2024 年起,它已经能用纯 CSS 实现展开和收起的动画。本文讲透整套技法:details[name]、::details-content、interpolate-size,以及那个让收起也能有动画的关键属性。

发布于 2026年7月28日9 分钟阅读

手风琴是 Web 上被重复实现最多的组件之一。常见的做法是:用一份 React state 记录当前展开项,用一个 onClick 来切换,再配上一些 CSS(或一个测量高度的 hook)来做展开动画。它能用,但这会把一段静态内容变成一个客户端组件,为了开合而下发并 hydrate 一堆 JavaScript。

其实浏览器多年前就有了原生的手风琴元素:<details>。它唯一的历史短板——没法做动画——从 2024 年起已经不复存在。本文将构建一个完整带动画、互斥展开、带旋转箭头的手风琴,而且零 JavaScript:没有 state,没有 hook,也没有 hydration。

人人都在下发的那种手风琴

下面是它最常见的写法——用 React state 记录展开项,用一个点击处理函数来切换,再用 grid-template-rows: 0fr → 1fr 这个技巧,无需测量任何东西就能给高度做动画:

Accordion.tsx
"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-contentinterpolate-sizeallow-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>&lt;details&gt;</code> + CSS
开 / 合React state + onClick浏览器内置
下发的 JavaScript有(客户端组件,需 hydrate)
平滑动画有(grid / 测量高度)有(::details-content
同时只开一项手动 state(openIdname 属性
键盘 + 无障碍你自己接(或用库)原生
hydrate 前 / 关掉 JS 时可用
箭头翻转由 JS 切换 class[open] 上用 CSS

当面板内容真正是动态的时候——按需加载、由应用状态驱动,或者展开必须触发你在 JS 里控制的副作用——JavaScript 依然更胜一筹。但对于最普遍的情形——一个展示型 FAQ 或展开列表——原生元素在功能上与 JS 版本一一对应,却没有那份 bundle 开销。

要点回顾

  • 人人都在写的那种 JavaScript 手风琴,之所以存在,很大程度上是因为原生 <details> 没法做动画——而这个限制现在已经没了。
  • ::details-content + interpolate-size: allow-keywords 能让高度在 0auto 之间做动画。
  • 收起(而不只是展开)也能有动画的诀窍,是在 content-visibility 上用 transition-behavior: allow-discrete
  • <details name="…"> 让你零 JavaScript 就能得到一个“同时只开一项”的互斥分组——每个手风琴用一个唯一的 name。
  • 箭头翻转就是一个绑定在 [open] 属性上的 CSS transform
  • 它无需 @supports 就能优雅降级:旧浏览器会瞬间开合,而动画只是纯粹的增强。

这些部件没有一个是稀奇的——<details> 已经存在了几十年,而那些动画特性也是无趣、规范定义清晰的 CSS。真正的转变,在于意识到你不再需要为了一个精致的手风琴而动用 React state。删掉那个客户端组件,下发静态 HTML,让浏览器去做它一直以来悄悄就能做到的事。