60fps-animation
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePerformant Web Animation
高性能Web动画
Eliminate the #1 cause of janky web animation: animating properties that force the browser to recalculate layout (reflow) or repaint on every frame. The browser renders in stages — layout → paint → composite. Animating , , , , , , , or re-runs layout and/or paint each frame, blocking the main thread. Animating and runs entirely on the compositor (often the GPU), skipping layout and paint, which is what makes animation smooth at 60/120fps.
widthheighttopleftmarginpaddingbox-shadowfiltertransformopacity消除Web动画卡顿的头号原因:动画那些会迫使浏览器每帧重新计算布局(回流)或重绘的属性。浏览器的渲染分为三个阶段 —— 布局 → 绘制 → 合成。对、、、、、、或执行动画时,每帧都会重新触发布局和/或绘制,阻塞主线程。而对**和**执行动画则完全在合成器(通常由GPU处理)上运行,跳过布局和绘制阶段,这正是动画能保持60/120fps流畅度的关键。
widthheighttopleftmarginpaddingbox-shadowfiltertransformopacityWhen to use
适用场景
Use when an animation stutters or drops frames, when a hover/scroll effect feels heavy, when animating size/position/shadow, when a layout change needs to animate smoothly (cards reordering, an element moving between containers), when animating , or when reviewing animation code for performance.
height: auto适用于动画出现卡顿或丢帧、悬停/滚动效果沉重、对尺寸/位置/阴影执行动画、布局变更需要平滑过渡(卡片重排、元素在容器间移动)、实现动画,或是审查动画代码性能的场景。
height: autoCore rule: animate only transform and opacity
核心规则:仅对transform和opacity执行动画
Map every "expensive" animation to a cheap equivalent.
| Animating (expensive) | Triggers | Replace with (cheap) |
|---|---|---|
| Layout + Paint | |
| Layout + Paint | |
| Paint | Animate |
| Paint (heavy) | Cross-fade two layers via |
| Paint | |
| Paint | Often acceptable; or cross-fade layers |
将所有“高开销”动画替换为低开销的等效实现。
| 高开销动画属性 | 触发操作 | 替换为低开销实现 |
|---|---|---|
| 布局 + 绘制 | |
| 布局 + 绘制 | |
| 绘制 | 对承载阴影的伪元素执行 |
| 高开销绘制 | 通过 |
| 绘制 | 对子图层执行 |
| 绘制 | 通常可接受;或使用图层交叉淡入淡出 |
Position and size via transform
通过transform实现位置与尺寸动画
css
/* BAD: animates layout every frame */
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
.box:hover { left: 200px; width: 200px; }
/* GOOD: compositor-only */
.box {
transition: transform 300ms ease;
transform: translateX(0) scaleX(1);
transform-origin: left center;
}
.box:hover { transform: translateX(200px) scaleX(2); }scaleXcss
/* 不良示例:每帧触发布局动画 */
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
.box:hover { left: 200px; width: 200px; }
/* 良好示例:仅使用合成器 */
.box {
transition: transform 300ms ease;
transform: translateX(0) scaleX(1);
transform-origin: left center;
}
.box:hover { transform: translateX(200px) scaleX(2); }scaleXCheap box-shadow via pseudo-element opacity
通过伪元素opacity实现低开销box-shadow动画
Animating repaints a large blurred region every frame. Instead paint the shadow once on a , then animate only its .
box-shadow::afteropacitycss
.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 28px rgba(0,0,0,0.35);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }The blurred shadow is rasterized once; hovering only changes a compositor opacity — smooth at any frame rate.
对执行动画时,每帧都会重绘大片模糊区域。替代方案是在伪元素上一次性绘制阴影,然后仅对其执行动画。
box-shadow::afteropacitycss
.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 28px rgba(0,0,0,0.35);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }模糊阴影仅被光栅化一次;悬停时仅改变合成器层面的透明度 —— 在任何帧率下都能保持流畅。
FLIP: animate layout changes cheaply
FLIP:低成本实现布局变更动画
FLIP (First, Last, Invert, Play) animates a layout change (reorder, resize, move between containers) using only . Measure where the element was (First) and will be (Last), apply an inverting transform so it visually appears unmoved, then animate the transform back to identity (Play). The DOM ends in its real final layout; the motion is pure compositor work.
transformjs
function flip(el, mutate) {
const first = el.getBoundingClientRect(); // First
mutate(); // change the DOM/layout
const last = el.getBoundingClientRect(); // Last
const dx = first.left - last.left;
const dy = first.top - last.top;
const sx = first.width / last.width;
const sy = first.height / last.height;
el.animate(
[
{ transformOrigin: 'top left',
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, // Invert
{ transformOrigin: 'top left', transform: 'none' }, // Play
],
{ duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
);
}
// usage: animate an element moving to a new grid position
flip(card, () => targetContainer.appendChild(card));The Web Animations API runs the transform on the compositor. For many elements, batch all reads before any mutation (see layout thrashing below).
animate()getBoundingClientRect()FLIP(First, Last, Invert, Play)仅使用即可实现布局变更动画(重排、 resize、容器间移动)。先测量元素的初始位置(First)和最终位置(Last),应用反向transform使其视觉上保持不动,然后将transform动画还原为初始状态(Play)。DOM最终会处于真实的布局状态;整个动画过程完全由合成器处理。
transformjs
function flip(el, mutate) {
const first = el.getBoundingClientRect(); // First
mutate(); // 修改DOM/布局
const last = el.getBoundingClientRect(); // Last
const dx = first.left - last.left;
const dy = first.top - last.top;
const sx = first.width / last.width;
const sy = first.height / last.height;
el.animate(
[
{ transformOrigin: 'top left',
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, // Invert
{ transformOrigin: 'top left', transform: 'none' }, // Play
],
{ duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
);
}
// 使用示例:动画元素移动到新的网格位置
flip(card, () => targetContainer.appendChild(card));Web Animations API的方法在合成器上运行transform。处理多个元素时,需在任何DOM修改前批量执行所有读取操作(见下文布局抖动部分)。
animate()getBoundingClientRect()Animating height: auto
实现height: auto动画
height: autocss
/* Modern (Chrome 129+/supporting browsers): opt size keywords into animation */
.panel {
interpolate-size: allow-keywords; /* enables animating to/from auto */
height: 0;
overflow: clip;
transition: height 300ms ease;
}
.panel.open { height: auto; }
/* calc-size() also works: height: calc-size(auto, size); */css
/* Robust fallback today: CSS grid 1fr -> 0fr */
.wrapper {
display: grid;
grid-template-rows: 0fr; /* collapsed */
transition: grid-template-rows 300ms ease;
}
.wrapper.open { grid-template-rows: 1fr; }
.wrapper > .content { overflow: hidden; min-height: 0; }The grid trick animates the track size (compositor-friendly enough and broadly supported) and needs no JS height measurement. Use / where the target browsers support it; keep the grid technique as the cross-browser default.
interpolate-size: allow-keywordscalc-size(auto, size)历史上无法被插值。以下是现代方案和兼容回退方案:
height: autocss
/* 现代方案(Chrome 129+/支持的浏览器):允许对尺寸关键字执行动画 */
.panel {
interpolate-size: allow-keywords; /* 启用向/从auto的动画 */
height: 0;
overflow: clip;
transition: height 300ms ease;
}
.panel.open { height: auto; }
/* calc-size()同样适用:height: calc-size(auto, size); */css
/* 当前稳健的回退方案:CSS grid 1fr → 0fr */
.wrapper {
display: grid;
grid-template-rows: 0fr; /* 收起状态 */
transition: grid-template-rows 300ms ease;
}
.wrapper.open { grid-template-rows: 1fr; }
.wrapper > .content { overflow: hidden; min-height: 0; }网格方案通过动画轨道尺寸实现效果(对合成器友好且兼容性广),无需JS测量高度。在目标浏览器支持的情况下使用 / ;将网格技术作为跨浏览器默认方案。
interpolate-size: allow-keywordscalc-size(auto, size)Avoid layout thrashing (batch reads, then writes)
避免布局抖动(批量读取,再批量写入)
Reading a layout property (, , , ) after a write forces a synchronous reflow. Interleaving reads and writes in a loop ("layout thrashing") can run dozens of forced reflows per frame.
offsetWidthgetBoundingClientRectscrollTopgetComputedStylejs
// BAD: read, write, read, write... forces reflow each iteration
items.forEach((el) => {
const w = el.offsetWidth; // read (forces layout)
el.style.width = w * 1.5 + 'px'; // write (invalidates layout)
});
// GOOD: batch all reads, then all writes
const widths = items.map((el) => el.offsetWidth); // all reads
items.forEach((el, i) => { // all writes
el.style.width = widths[i] * 1.5 + 'px';
});For frame-synced work, read in a callback and apply writes; libraries like fastdom formalize this read/write scheduling.
requestAnimationFrame在写入操作后读取布局属性(、、、)会强制触发同步回流。在循环中交替执行读取和写入操作(“布局抖动”)会导致每帧触发数十次强制回流。
offsetWidthgetBoundingClientRectscrollTopgetComputedStylejs
// 不良示例:读取、写入、读取、写入……每次迭代都强制回流
items.forEach((el) => {
const w = el.offsetWidth; // 读取(强制布局)
el.style.width = w * 1.5 + 'px'; // 写入(使布局失效)
});
// 良好示例:批量执行所有读取,再批量执行所有写入
const widths = items.map((el) => el.offsetWidth); // 全部读取
items.forEach((el, i) => { // 全部写入
el.style.width = widths[i] * 1.5 + 'px';
});对于帧同步操作,在回调中执行读取,然后应用写入;fastdom等库可以规范这种读取/写入调度。
requestAnimationFramewill-change: use sparingly
will-change:谨慎使用
will-change: transformcss
.menu { will-change: transform; } /* only on elements about to animate */Rules: apply just before the animation (e.g. on hover/parent state), remove it after () when idle, never blanket-apply to many elements, and never leave it permanently on large/numerous nodes. A single hack does the same promotion but is harder to undo — prefer .
will-change: autotransform: translateZ(0)will-changewill-change: transformcss
.menu { will-change: transform; } /* 仅对即将执行动画的元素使用 */使用规则:仅在动画即将开始前应用(如在悬停/父元素状态变化时),动画结束后移除(),绝不要批量应用到多个元素,也不要长期保留在大型/大量节点上。技巧也能实现同样的图层提升,但难以撤销 —— 优先使用。
will-change: autotransform: translateZ(0)will-changeDeliver & verify (standalone HTML)
交付与验证(独立HTML文件)
Packaged helper ():scripts/freezes thescripts/seek-shot.sh anim.html 0 1.5 3harness and screenshots each moment;?t=Ntiles them for one-glance review. Seescripts/contact-sheet.sh sheet.png frame-*.png.scripts/README.md
The deliverable is one self-contained that opens directly in a browser — the markup, the CSS/JS animation, and a freeze harness in one file. For this skill, verification is two-pronged: the frame must look right and be cheap to produce (compositor-only).
.htmlOutput contract:
- One , deps via CDN if any, animation driven by CSS transitions/
.htmlor the Web Animations API.@keyframes - A freeze mechanism so a screenshot lands on a deterministic frame:
- CSS →
@keyframessets?t=N.el.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused' - WAAPI / JS → keep the animation object and .
anim.pause(); anim.currentTime = N*1000
- CSS
Verify loop — freeze → screenshot, then profile for jank:
- Open headless at start / mid / end (, mid, end), screenshot each; confirm the motion is visually correct (no clipped/stretched text from
?t=0, FLIP lands on the real layout, shadow/height transitions look right).scaleX - Confirm it's compositor-only — the whole point of this skill. Either:
- DevTools → Performance: record the animation, check there is no purple "Layout" or green "Paint" band per frame (only "Composite Layers").
- Or headless trace: for the visual, plus a CDP/
npx playwright screenshotcapture, and assert notracing/Layoutevents fire during the animation window.Paint
- Watch the FPS/“Frame Rendering Stats” overlay stays at 60 — dropped frames mean an expensive property slipped back in.
bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.pngBefore you finish:
- Opens standalone — no console errors.
- Only /
transformanimate per frame; DevTools shows no per-frame Layout/Paint.opacity - Screenshotted at start / mid / end — correct, no text distortion, FLIP lands true.
scaleX - Holds 60fps; applied just-in-time and removed when idle (no leftover layers).
will-change - honored.
prefers-reduced-motion
打包工具(目录):scripts/会冻结scripts/seek-shot.sh anim.html 0 1.5 3测试环境并截取每个时刻的截图;?t=N会将截图拼接成一张预览图,便于快速查看。详见scripts/contact-sheet.sh sheet.png frame-*.png。scripts/README.md
交付物为单个独立的文件,可直接在浏览器中打开 —— 包含标记语言、CSS/JS动画,以及冻结测试环境的代码。对于此技能,验证分为两部分:帧视觉效果正确 且 渲染成本低(仅合成器处理)。
.html输出规范:
- 单个文件,依赖项可通过CDN引入,动画由CSS过渡/
.html或Web Animations API驱动。@keyframes - 包含冻结机制,确保截图能捕获确定的帧:
- CSS →
@keyframes设置?t=N。el.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused' - WAAPI / JS → 保留动画对象并执行。
anim.pause(); anim.currentTime = N*1000
- CSS
验证流程 —— 冻结 → 截图,然后分析卡顿情况:
- 在起始/中间/结束帧打开无头浏览器(、中间值、结束值),分别截图;确认动画视觉效果正确(
?t=0未导致文字裁剪/拉伸、FLIP最终处于真实布局、阴影/高度过渡效果正常)。scaleX - 确认仅使用合成器 —— 这是此技能的核心目标。可通过以下两种方式验证:
- DevTools → Performance:录制动画,检查每帧是否**没有紫色“Layout”或绿色“Paint”**条带(仅显示“Composite Layers”)。
- 或无头追踪:使用获取视觉截图,同时捕获CDP/
npx playwright screenshot记录,断言动画期间未触发tracing/Layout事件。Paint
- 观察FPS/“Frame Rendering Stats” overlay保持在60 —— 丢帧意味着引入了高开销属性。
bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png完成前检查:
- 可独立打开 —— 无控制台错误。
- 每帧仅对/
transform执行动画;DevTools显示每帧无Layout/Paint操作。opacity - 在起始/中间/结束帧截图 —— 效果正确,无文字变形,FLIP最终位置准确。
scaleX - 保持60fps;仅在需要时应用,空闲时移除(无残留图层)。
will-change - 遵循设置。
prefers-reduced-motion
Quick reference
速查指南
| Goal | Do this |
|---|---|
| Move element | |
| Resize without distortion | FLIP with |
| Shadow on hover | Animate |
| Expand to content height | grid |
| Many elements moving | Batch |
| Smooth animation start | |
| Verify it's compositor-only | DevTools Performance: no purple "Layout"/green "Paint" per frame |
| 目标 | 实现方式 |
|---|---|
| 移动元素 | |
| 无变形调整尺寸 | 搭配 |
| 悬停阴影效果 | 对阴影伪元素执行 |
| 展开至内容高度 | grid |
| 多元素移动 | 批量执行 |
| 平滑启动动画 | 仅在需要时应用 |
| 验证仅使用合成器 | DevTools Performance:每帧无紫色“Layout”/绿色“Paint”条带 |
Gotchas
注意事项
- stretches text and children; use FLIP when content must stay crisp.
scaleX/scaleY - percentages are relative to the element's own box, not the parent — different from
transform.left: % - Overusing or
will-changecreates too many layers and hurts performance; promote only what animates.translateZ(0) - and
filterare compositor-related but still expensive; animate their presence via opacity cross-fades rather than animating the blur radius.backdrop-filter - The grid content must have
1fr→0frandoverflow: hiddenor it won't collapse.min-height: 0 - Always gate non-essential motion behind .
@media (prefers-reduced-motion: reduce)
- 会拉伸文字和子元素;如需内容保持清晰,请使用FLIP。
scaleX/scaleY - 的百分比基于元素自身的盒子,而非父元素 —— 与
transform不同。left: % - 过度使用或
will-change会创建过多图层,反而降低性能;仅提升需要动画的元素。translateZ(0) - 和
filter与合成器相关,但开销仍较高;通过透明度交叉淡入淡出控制其显示,而非动画模糊半径。backdrop-filter - grid 方案中的内容必须设置
1fr→0fr和overflow: hidden,否则无法收起。min-height: 0 - 务必通过控制非必要动画。
@media (prefers-reduced-motion: reduce)
Reference files
参考文件
- — full runnable examples (accordion, reorder list, parallax), DevTools profiling walkthrough to confirm compositor-only frames, reduced-motion patterns, and a property-cost cheat sheet.
references/patterns-and-profiling.md
- —— 完整可运行示例(手风琴、重排列表、视差滚动)、DevTools性能分析指南(确认仅合成器帧)、简化动画方案,以及属性开销速查表。
references/patterns-and-profiling.md