抖动根源是scrollHeight读取不准与强制重排时机不当导致max-height跳变;应改用getBoundingClientRect()与offsetHeight组合校验,并在恰当渲染帧内完成重排。

details 展开时页面抖动不是 details 本身的问题
抖动源头其实是 scrollHeight 读取不准 + 强制重排时机不当,导致 max-height 过渡高度跳变,进而触发布局重排(reflow)并影响周边元素定位。这不是 <details> 的 bug,而是用 JS 模拟动画时踩进的渲染管线坑。
为什么给 content 设置 max-height 会引发抖动
-
content.scrollHeight 在图片未加载、字体未就绪或行高未生效时返回偏小值,动画中途突然“撑开”,视觉上就是一卡一跳
- 直接设
max-height: 0 → 强制 offsetHeight → 再设 max-height: scrollHeight,这三步若没在单帧内完成,浏览器可能插入其他样式计算或布局任务,打断过渡连贯性
- 若内容区有
margin-top/bottom 或 padding,scrollHeight 不包含它,但实际高度包含——差值会让过渡终点错位
真正稳的展开收起写法(含防抖+重排控制)
content.scrollHeight 在图片未加载、字体未就绪或行高未生效时返回偏小值,动画中途突然“撑开”,视觉上就是一卡一跳max-height: 0 → 强制 offsetHeight → 再设 max-height: scrollHeight,这三步若没在单帧内完成,浏览器可能插入其他样式计算或布局任务,打断过渡连贯性margin-top/bottom 或 padding,scrollHeight 不包含它,但实际高度包含——差值会让过渡终点错位核心是:不依赖 scrollHeight 做唯一依据,用 getBoundingClientRect() + offsetHeight 组合校验,并把重排压缩到最小粒度:
const details = document.querySelector('details');
const content = details.querySelector(':scope > *:not(summary)');
details.addEventListener('toggle', () => {
if (!content) return;
// 收起:先设目标高度,再强制重排,再归零
if (!details.open) {
content.style.maxHeight = content.scrollHeight + 'px';
content.offsetHeight; // 触发重排
content.style.maxHeight = '0';
return;
}
// 展开:先归零,再重排,再设为「安全上限」而非 scrollHeight
content.style.maxHeight = '0';
content.offsetHeight;
// 用 getBoundingClientRect().height 替代 scrollHeight,更可靠
const rect = content.getBoundingClientRect();
const computed = getComputedStyle(content);
const safeHeight = Math.max(
rect.height,
parseFloat(computed.paddingTop) + parseFloat(computed.paddingBottom) +
parseFloat(computed.marginTop) + parseFloat(computed.marginBottom)
) + 10; // 加 10px 余量防截断
content.style.maxHeight = safeHeight + 'px';
});
同时必须加 CSS:
details > *:not(summary) {
overflow: hidden;
transition: max-height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
容易被忽略的 Safari 兼容细节
- Safari 16.4 以下不支持
summary::marker { display: none },想隐藏原生箭头得用 list-style: none + summary::before 自绘图标
- Safari 对
getBoundingClientRect() 在 display: none 元素上返回 { height: 0 },所以不能在 details[open] 为 false 时提前读 —— 必须等 open 为 true 后再读
- 若内容含
<img>,需监听 content.querySelectorAll('img').forEach(img => img.addEventListener('load', rerunHeightCalc)),否则首展大概率抖
真正的难点不在“怎么加动画”,而在“怎么让浏览器相信这个高度是可信的”。scrollHeight 是个懒值,getBoundingClientRect() 是个快照,两者都得在恰当的渲染阶段读取——错过那一帧,抖动就藏不住。
summary::marker { display: none },想隐藏原生箭头得用 list-style: none + summary::before 自绘图标getBoundingClientRect() 在 display: none 元素上返回 { height: 0 },所以不能在 details[open] 为 false 时提前读 —— 必须等 open 为 true 后再读<img>,需监听 content.querySelectorAll('img').forEach(img => img.addEventListener('load', rerunHeightCalc)),否则首展大概率抖



















