CSS Disclosure 动画实现指南
适用场景:开关、accordion、详情面板、advanced options、filter panel、ControlNet 这类“标题行 + 可变高度内容”的展开收起动画。
核心目标不是“有动画”,而是:
- 关闭态不残留 gap。
- 展开时高度自然跟随真实内容,不依赖魔法数字。
- 内容不被挤压、闪烁或迟滞。
- React 中复杂内容挂载不会和高度动画抢同一帧。
- reduced motion 下能稳定降级。
1. 常见问题
父级 gap 残留
典型结构:
<section class="section">
<header>Advanced Options</header>
<div class="panel">...</div>
</section>如果 .section 固定 gap: 8px,即使 .panel 折叠到 height: 0,它仍然是第二个 flex 子元素,父级 gap 还会保留。
修法:父级默认 gap: 0,只在视觉展开态加 gap。
.section {
display: flex;
flex-direction: column;
gap: 0;
}
.section.is-expanded {
gap: 8px;
}max-height 动画不匀速
常见写法:
.panel {
max-height: 0;
overflow: hidden;
transition: max-height 240ms ease;
}
.panel.is-open {
max-height: 430px;
}问题是内容真实高度可能只有 180px,动画却要跑到 430px。视觉上会出现打开末尾空跑、关闭开头停顿,手感不均匀。
推荐改成 CSS grid 的 intrinsic height 动画。
2. 推荐结构
<section class="disclosure is-expanded">
<button class="disclosure__trigger" type="button">
Advanced Options
</button>
<div class="disclosure__panel">
<div class="disclosure__inner">
<div class="field">Seed</div>
<div class="field">Mode</div>
<div class="field">Negative prompt</div>
</div>
</div>
</section>分工:
section:负责标题和内容之间的 gap。panel:负责高度、opacity、pointer-events、visibility。inner:负责真实内容布局。
不要让同一个元素同时负责“高度折叠”和“内容布局”。外层折叠,内层布局,这个边界会少很多怪问题。
3. Pure CSS Demo
<label class="demo-toggle">
<input type="checkbox" hidden />
<span class="demo-trigger">Advanced Options</span>
<span class="demo-panel">
<span class="demo-inner">
<span class="demo-row">Seed: 1024</span>
<span class="demo-row">Mode: Recon</span>
<span class="demo-row">Cfg: 7.5</span>
<span class="demo-row">Negative prompt: low quality</span>
</span>
</span>
</label>.demo-toggle {
display: flex;
width: 320px;
flex-direction: column;
gap: 0;
border-radius: 12px;
background: #1f1f25;
padding: 12px;
color: #fff;
transition: gap 220ms ease;
}
.demo-toggle:has(input:checked) {
gap: 8px;
}
.demo-trigger {
display: flex;
min-height: 28px;
align-items: center;
justify-content: space-between;
cursor: pointer;
font-weight: 700;
}
.demo-panel {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
opacity: 0;
pointer-events: none;
transform: translateY(-2px);
visibility: hidden;
transition:
grid-template-rows 260ms ease,
opacity 180ms ease,
transform 220ms ease,
visibility 0s linear 260ms;
}
.demo-toggle:has(input:checked) .demo-panel {
grid-template-rows: 1fr;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
visibility: visible;
transition:
grid-template-rows 260ms ease,
opacity 180ms ease,
transform 220ms ease,
visibility 0s;
}
.demo-inner {
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.demo-inner > * {
flex: 0 0 auto;
}
.demo-row {
display: block;
border-radius: 8px;
background: rgb(255 255 255 / 8%);
padding: 8px 10px;
}
@media (prefers-reduced-motion: reduce) {
.demo-toggle,
.demo-panel {
transition: none;
}
}这个方案的关键是 grid-template-rows: 0fr -> 1fr。它能让内容高度由真实内容决定,不需要猜 max-height。
4. React 中的复杂内容
如果内容一直在 DOM 中,直接切 is-open 通常够用。
但如果内容很重,例如:
- canvas / WebGL / Three frame
- image upload cards
- tab segment + dynamic panel
- 异步挂载的复杂组件
不要让“内容首次挂载”和“高度开始展开”发生在同一帧。否则浏览器要同时做 DOM 插入、样式计算、布局和动画起始帧,容易出现迟钝或先挤压后展开。
推荐用两阶段状态:
import { useEffect, useState } from 'react';
function Disclosure({ open, children }) {
const [mounted, setMounted] = useState(open);
const [expanded, setExpanded] = useState(open);
useEffect(() => {
const prefersReducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)'
).matches;
if (open) {
setMounted(true);
if (prefersReducedMotion) {
setExpanded(true);
return undefined;
}
const frameId = window.requestAnimationFrame(() => {
setExpanded(true);
});
return () => window.cancelAnimationFrame(frameId);
}
setExpanded(false);
if (prefersReducedMotion) {
setMounted(false);
}
return undefined;
}, [open]);
const handleTransitionEnd = (event) => {
if (
event.target !== event.currentTarget ||
event.propertyName !== 'grid-template-rows' ||
open ||
expanded
) {
return;
}
setMounted(false);
};
return (
<section className={`disclosure ${expanded ? 'is-expanded' : ''}`}>
<button type="button">Toggle</button>
<div
aria-hidden={!expanded}
className={`disclosure__panel ${expanded ? 'is-open' : ''}`}
onTransitionEnd={handleTransitionEnd}
>
<div className="disclosure__inner">{mounted ? children : null}</div>
</div>
</section>
);
}这段状态的含义:
open:业务开关。mounted:内容是否在 DOM 中。expanded:视觉上是否展开。
打开流程:
open = true- 先
mounted = true,让内容进入 DOM。 - 下一帧
expanded = true,开始高度动画。
关闭流程:
open = false- 先
expanded = false,执行收起动画。 grid-template-rowstransition 结束后mounted = false。
5. 防挤压规则
如果 inner 是 flex column,子元素默认 flex-shrink: 1。在 panel 从 0fr 展开的早期,容器高度很小,子元素可能先被压扁。
给直接子元素加:
.disclosure__inner > * {
flex: 0 0 auto;
}这会让内容保持自己的自然高度,由外层 clip 掉还没展开的部分,而不是把内容本身挤变形。
6. Gap 应绑定视觉态,不一定绑定业务态
简单内容可以这样:
<section className={open ? 'section is-expanded' : 'section'} />复杂内容更推荐:
<section className={expanded ? 'section is-expanded' : 'section'} />原因是复杂内容通常会先 mount、下一帧再 expanded。如果 gap 绑定 open,gap 会先出现,而内容后一帧才展开,视觉上也会像“顿了一下”。gap 应该和 panel.is-open 使用同一个视觉态。
7. visibility 与 pointer-events
折叠态建议同时处理交互:
.disclosure__panel {
pointer-events: none;
visibility: hidden;
transition:
grid-template-rows 260ms ease,
opacity 180ms ease,
visibility 0s linear 260ms;
}
.disclosure__panel.is-open {
pointer-events: auto;
visibility: visible;
transition:
grid-template-rows 260ms ease,
opacity 180ms ease,
visibility 0s;
}这样折叠内容不会被鼠标点到,也不会在关闭动画结束前突然 display: none。
8. 什么时候不要用这个方案
以下情况要谨慎:
- 内容高度极大,并且展开会推动很长页面重排。
- 每次展开都会初始化昂贵 canvas / WebGL。
- panel 内部有虚拟列表或自己管理滚动高度。
- 内容在展开过程中不断异步增高。
这些场景可以考虑:
- 内容先预加载或保持 mounted。
- 外层固定一个合理 viewport,高度内部滚动。
- 用 transform / opacity 做浮层式 reveal,而不是推动文档流。
- 把 heavy canvas 放在稳定尺寸容器里,避免动画过程中重复测量。
9. Checklist
实现 disclosure 动画前检查:
- 父级关闭态是否还有 gap。
- 是否用了固定
max-height魔法数字。 - 折叠外层和内容布局是否分层。
- inner 直接子元素是否会被 flex shrink。
- 复杂内容是否先 mount、下一帧再 open。
- 关闭时是否等动画结束再卸载。
aria-hidden、pointer-events、visibility是否同步。prefers-reduced-motion是否禁用 transition。- 测试是否覆盖了 CSS 契约,避免后续回退。
10. 推荐模板
.disclosure {
display: flex;
flex-direction: column;
gap: 0;
transition: gap 220ms ease;
}
.disclosure.is-expanded {
gap: 8px;
}
.disclosure__panel {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
pointer-events: none;
opacity: 0;
transform: translateY(-2px);
visibility: hidden;
transition:
grid-template-rows 260ms ease,
opacity 180ms ease,
transform 220ms ease,
visibility 0s linear 260ms;
}
.disclosure__panel.is-open {
grid-template-rows: 1fr;
pointer-events: auto;
opacity: 1;
transform: translateY(0);
visibility: visible;
transition:
grid-template-rows 260ms ease,
opacity 180ms ease,
transform 220ms ease,
visibility 0s;
}
.disclosure__inner {
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.disclosure__inner > * {
flex: 0 0 auto;
}
@media (prefers-reduced-motion: reduce) {
.disclosure,
.disclosure__panel {
transition: none;
}
}一句话总结:可变高度展开动画的稳定实现,不是调一个更丝滑的 easing,而是把“是否有 gap”“内容是否已挂载”“视觉是否展开”“真实内容如何布局”拆成清晰的层和状态。