React 折叠交互:平滑动画、快速反向与可访问性
用 CSS Grid 和保留 DOM 实现折叠区域,处理快速反向点击、焦点、inert 与减少动画设置,并区分组件测试和浏览器验收。
工作台里有很多折叠区域:文件树、字段分组、附件详情和侧栏面板。单独看,每一处都只是一个布尔状态;放在同一个界面里,展开速度、箭头反馈和焦点行为不一致,就会明显影响操作节奏。
之前整理这类交互时,我希望得到一个简单的共用实现:内容高度可以变化,快速点击时能够反向,表单状态可以保留,隐藏后又不会被键盘误入。动画只占其中一部分。
下面的示例适用于普通内容分组,采用现代 React 的 inert 属性写法。虚拟列表、嵌套滚动区和需要卸载大量内容的场景,应再单独评估。
先决定是否保留内容
open && <Content /> 很直接,但关闭时内容立即从 DOM 消失,退出动画来不及完成,内部未提升的状态也会丢失。对反复展开的表单分组,保留 DOM 通常更符合用户预期。
代价也很实际:关闭的组件仍可能保留订阅、定时器和数据请求。大型列表不能仅为动画长期挂载所有内容。是否卸载,应由状态保留需求和资源成本决定,不能让折叠容器替所有场景做同一个选择。
让布局参与过渡
对自然高度内容,可以让外层使用 Grid,在 0fr 与 1fr 之间过渡。内层设置 min-height: 0 和裁剪,避免内容的最小尺寸把关闭状态撑开。
.collapse-region {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
transition: grid-template-rows 160ms ease-out,
opacity 160ms ease-out, visibility 0s;
}
.collapse-region[data-state="closed"] {
grid-template-rows: 0fr;
opacity: 0;
visibility: hidden;
transition: grid-template-rows 160ms ease-out,
opacity 160ms ease-out, visibility 0s 160ms;
}
.collapse-inner { min-height: 0; overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
.collapse-region,
.collapse-region[data-state="closed"] { transition: none; }
}
关闭时延迟切换 visibility,内容才能完成高度和透明度变化;打开时则立即恢复可见。减少动画设置下,布局直接到达目标状态。MDN 的 prefers-reduced-motion 文档解释了该媒体查询的用户偏好来源。
快速点击时不必额外排队执行动画,状态改变会让浏览器从当前插值位置向新目标过渡。相比“等待旧动画完成再响应”,这种反馈更接近用户操作。但需要在目标浏览器里检查,因为布局、内容尺寸和嵌套过渡都可能影响实际效果。
视觉隐藏与交互隐藏一起处理
透明度为零不等于无法操作,aria-hidden 也不会自动移除内部控件的键盘焦点。关闭状态还需要让整个区域停止交互。
inert 可以让后代退出焦点和交互路径,并影响可访问性树;它的具体行为与支持情况可查阅 MDN 的 inert 说明。使用时仍要处理一个重要时序:如果焦点原来在内容里,应先把它移回触发按钮,再让区域进入 inert。
下面是一个可独立组合的组件示例:
import { useId, useRef, useState } from 'react';
import type { ReactNode } from 'react';
export function Disclosure({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const id = useId();
const trigger = useRef<HTMLButtonElement>(null);
const region = useRef<HTMLDivElement>(null);
function toggle() {
if (open && region.current?.contains(document.activeElement)) {
trigger.current?.focus();
}
setOpen(value => !value);
}
return <section>
<button ref={trigger} type="button" onClick={toggle}
aria-expanded={open} aria-controls={id}>
规格详情
</button>
<div ref={region} id={id} className="collapse-region"
data-state={open ? 'open' : 'closed'}
inert={!open} aria-hidden={!open}>
<div className="collapse-inner">{children}</div>
</div>
</section>;
}
原生按钮提供键盘激活行为,aria-expanded 表达当前状态,aria-controls 关联内容区域。如果外部还有“全部收起”,也必须复用焦点处理,不能绕过按钮直接修改状态。上面的局部示例没有暴露受控状态,加入这类能力时需要把关闭入口统一起来。
内层布局也会影响结果
示例使用 overflow: hidden 适合普通内容。真正的工作台可能要求内部可滚动,采用 overflow: auto 时,还要检查滚动条是否在过渡中闪现、关闭后是否保留滚动位置。浮层菜单和焦点描边也可能被裁剪,应按具体组件处理。
间距最好放在可收缩的内部包装中,并避免外部固定高度或不参与收缩的 margin 留下空白。箭头旋转与内容过渡可以使用同一个时长,但不需要为每一行增加独立动画;在信息密集的界面里,连续响应比复杂效果更重要。
分开验证状态和观感
组件测试适合检查展开状态、关联 ID、inert、内容是否保留,以及关闭后焦点是否返回。真实浏览器还要观察中间帧、快速反向、长内容、滚动和减少动画偏好。这两种验证互相补充,DOM 属性正确不能证明动画自然。
我在历史任务中对部分控件做过浏览器检查,但没有逐个完成所有折叠控件的慢速验收,因此不能把那次结果概括为整个工作台都已验证。后续扩展共用组件时,应把代表场景列清楚,再逐步替换。
一个好的折叠交互,首先应该让内容和焦点按预期移动。短而连续的动画能帮助用户理解变化,但在用户要求减少动画时,直接完成同样的状态切换也应该完全可用。