Skip to content

CSS Disclosure 动画实现指南 ​

适用场景:开关、accordion、详情面板、advanced options、filter panel、ControlNet 这类“标题行 + 可变高度内容”的展开收起动画。

核心目标不是“有动画”,而是:

  • 关闭态不残留 gap。
  • 展开时高度自然跟随真实内容,不依赖魔法数字。
  • 内容不被挤压、闪烁或迟滞。
  • React 中复杂内容挂载不会和高度动画抢同一帧。
  • reduced motion 下能稳定降级。

1. 常见问题 ​

父级 gap 残留 ​

典型结构:

html
<section class="section">
  <header>Advanced Options</header>
  <div class="panel">...</div>
</section>

如果 .section 固定 gap: 8px,即使 .panel 折叠到 height: 0,它仍然是第二个 flex 子元素,父级 gap 还会保留。

修法:父级默认 gap: 0,只在视觉展开态加 gap。

css
.section {
  display: flex;
  flex-direction: column;
  gap: 0;
}

.section.is-expanded {
  gap: 8px;
}

max-height 动画不匀速 ​

常见写法:

css
.panel {
  max-height: 0;
  overflow: hidden;
  transition: max-height 240ms ease;
}

.panel.is-open {
  max-height: 430px;
}

问题是内容真实高度可能只有 180px,动画却要跑到 430px。视觉上会出现打开末尾空跑、关闭开头停顿,手感不均匀。

推荐改成 CSS grid 的 intrinsic height 动画。

2. 推荐结构 ​

html
<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 ​

html
<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>
css
.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 插入、样式计算、布局和动画起始帧,容易出现迟钝或先挤压后展开。

推荐用两阶段状态:

jsx
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:视觉上是否展开。

打开流程:

  1. open = true
  2. 先 mounted = true,让内容进入 DOM。
  3. 下一帧 expanded = true,开始高度动画。

关闭流程:

  1. open = false
  2. 先 expanded = false,执行收起动画。
  3. grid-template-rows transition 结束后 mounted = false。

5. 防挤压规则 ​

如果 inner 是 flex column,子元素默认 flex-shrink: 1。在 panel 从 0fr 展开的早期,容器高度很小,子元素可能先被压扁。

给直接子元素加:

css
.disclosure__inner > * {
  flex: 0 0 auto;
}

这会让内容保持自己的自然高度,由外层 clip 掉还没展开的部分,而不是把内容本身挤变形。

6. Gap 应绑定视觉态,不一定绑定业务态 ​

简单内容可以这样:

jsx
<section className={open ? 'section is-expanded' : 'section'} />

复杂内容更推荐:

jsx
<section className={expanded ? 'section is-expanded' : 'section'} />

原因是复杂内容通常会先 mount、下一帧再 expanded。如果 gap 绑定 open,gap 会先出现,而内容后一帧才展开,视觉上也会像“顿了一下”。gap 应该和 panel.is-open 使用同一个视觉态。

7. visibility 与 pointer-events ​

折叠态建议同时处理交互:

css
.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. 推荐模板 ​

css
.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”“内容是否已挂载”“视觉是否展开”“真实内容如何布局”拆成清晰的层和状态。