60fps-animation

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Performant 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
width
,
height
,
top
,
left
,
margin
,
padding
,
box-shadow
, or
filter
re-runs layout and/or paint each frame, blocking the main thread. Animating
transform
and
opacity
runs entirely on the compositor (often the GPU), skipping layout and paint, which is what makes animation smooth at 60/120fps.
消除Web动画卡顿的头号原因:动画那些会迫使浏览器每帧重新计算布局(回流)或重绘的属性。浏览器的渲染分为三个阶段 —— 布局 → 绘制 → 合成。对
width
height
top
left
margin
padding
box-shadow
filter
执行动画时,每帧都会重新触发布局和/或绘制,阻塞主线程。而对**
transform
opacity
**执行动画则完全在合成器(通常由GPU处理)上运行,跳过布局和绘制阶段,这正是动画能保持60/120fps流畅度的关键。

When 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
height: auto
, or when reviewing animation code for performance.
适用于动画出现卡顿或丢帧、悬停/滚动效果沉重、对尺寸/位置/阴影执行动画、布局变更需要平滑过渡(卡片重排、元素在容器间移动)、实现
height: auto
动画,或是审查动画代码性能的场景。

Core rule: animate only transform and opacity

核心规则:仅对transform和opacity执行动画

Map every "expensive" animation to a cheap equivalent.
Animating (expensive)TriggersReplace with (cheap)
width
/
height
Layout + Paint
transform: scaleX()/scaleY()
(+ FLIP for true size)
top
/
left
/
margin
Layout + Paint
transform: translate()
box-shadow
PaintAnimate
opacity
of a pseudo-element holding the shadow
filter: blur()
Paint (heavy)Cross-fade two layers via
opacity
, or accept sparingly
background-position
Paint
transform: translate()
on a child layer
color
/
background
PaintOften acceptable; or cross-fade layers
将所有“高开销”动画替换为低开销的等效实现。
高开销动画属性触发操作替换为低开销实现
width
/
height
布局 + 绘制
transform: scaleX()/scaleY()
(如需真实尺寸搭配FLIP)
top
/
left
/
margin
布局 + 绘制
transform: translate()
box-shadow
绘制对承载阴影的伪元素执行
opacity
动画
filter: blur()
高开销绘制通过
opacity
交叉淡入淡出两个图层,或谨慎使用
background-position
绘制对子图层执行
transform: translate()
color
/
background
绘制通常可接受;或使用图层交叉淡入淡出

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); }
scaleX
distorts inner content (text stretches). For true resizing without distortion, use FLIP.
css
/* 不良示例:每帧触发布局动画 */
.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); }
scaleX
会拉伸内部内容(如文字变形)。如需无变形的真实尺寸调整,请使用FLIP

Cheap box-shadow via pseudo-element opacity

通过伪元素opacity实现低开销box-shadow动画

Animating
box-shadow
repaints a large blurred region every frame. Instead paint the shadow once on a
::after
, then animate only its
opacity
.
css
.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
执行动画时,每帧都会重绘大片模糊区域。替代方案是在
::after
伪元素上一次性绘制阴影,然后仅对其
opacity
执行动画。
css
.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
transform
. 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.
js
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
animate()
runs the transform on the compositor. For many elements, batch all
getBoundingClientRect()
reads before any mutation (see layout thrashing below).
FLIP(First, Last, Invert, Play)仅使用
transform
即可实现布局变更动画(重排、 resize、容器间移动)。先测量元素的初始位置(First)和最终位置(Last),应用反向transform使其视觉上保持不动,然后将transform动画还原为初始状态(Play)。DOM最终会处于真实的布局状态;整个动画过程完全由合成器处理。
js
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的
animate()
方法在合成器上运行transform。处理多个元素时,需在任何DOM修改前批量执行所有
getBoundingClientRect()
读取操作(见下文布局抖动部分)。

Animating height: auto

实现height: auto动画

height: auto
is not interpolatable historically. Modern and fallback approaches:
css
/* 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
interpolate-size: allow-keywords
/
calc-size(auto, size)
where the target browsers support it; keep the grid technique as the cross-browser default.
历史上
height: auto
无法被插值。以下是现代方案和兼容回退方案:
css
/* 现代方案(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-keywords
/
calc-size(auto, size)
;将网格技术作为跨浏览器默认方案。

Avoid layout thrashing (batch reads, then writes)

避免布局抖动(批量读取,再批量写入)

Reading a layout property (
offsetWidth
,
getBoundingClientRect
,
scrollTop
,
getComputedStyle
) after a write forces a synchronous reflow. Interleaving reads and writes in a loop ("layout thrashing") can run dozens of forced reflows per frame.
js
// 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
requestAnimationFrame
callback and apply writes; libraries like fastdom formalize this read/write scheduling.
在写入操作后读取布局属性(
offsetWidth
getBoundingClientRect
scrollTop
getComputedStyle
)会强制触发同步回流。在循环中交替执行读取和写入操作(“布局抖动”)会导致每帧触发数十次强制回流。
js
// 不良示例:读取、写入、读取、写入……每次迭代都强制回流
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';
});
对于帧同步操作,在
requestAnimationFrame
回调中执行读取,然后应用写入;fastdom等库可以规范这种读取/写入调度。

will-change: use sparingly

will-change:谨慎使用

will-change: transform
promotes an element to its own compositor layer ahead of time, avoiding a hitch at animation start. But every promoted layer costs GPU memory, and over-use degrades performance.
css
.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 (
will-change: auto
) when idle, never blanket-apply to many elements, and never leave it permanently on large/numerous nodes. A single
transform: translateZ(0)
hack does the same promotion but is harder to undo — prefer
will-change
.
will-change: transform
会提前将元素提升到独立的合成器图层,避免动画开始时出现卡顿。但每个提升的图层都会占用GPU内存,过度使用会降低性能。
css
.menu { will-change: transform; }   /* 仅对即将执行动画的元素使用 */
使用规则:仅在动画即将开始前应用(如在悬停/父元素状态变化时),动画结束后移除(
will-change: auto
),绝不要批量应用到多个元素,也不要长期保留在大型/大量节点上。
transform: translateZ(0)
技巧也能实现同样的图层提升,但难以撤销 —— 优先使用
will-change

Deliver & verify (standalone HTML)

交付与验证(独立HTML文件)

Packaged helper (
scripts/
):
scripts/seek-shot.sh anim.html 0 1.5 3
freezes the
?t=N
harness and screenshots each moment;
scripts/contact-sheet.sh sheet.png frame-*.png
tiles them for one-glance review. See
scripts/README.md
.
The deliverable is one self-contained
.html
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).
Output contract:
  • One
    .html
    , deps via CDN if any, animation driven by CSS transitions/
    @keyframes
    or the Web Animations API.
  • A freeze mechanism so a screenshot lands on a deterministic frame:
    • CSS
      @keyframes
      ?t=N
      sets
      el.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused'
      .
    • WAAPI / JS → keep the animation object and
      anim.pause(); anim.currentTime = N*1000
      .
Verify loop — freeze → screenshot, then profile for jank:
  1. Open headless at start / mid / end (
    ?t=0
    , mid, end), screenshot each; confirm the motion is visually correct (no clipped/stretched text from
    scaleX
    , FLIP lands on the real layout, shadow/height transitions look right).
  2. 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:
      npx playwright screenshot
      for the visual, plus a CDP/
      tracing
      capture, and assert no
      Layout
      /
      Paint
      events fire during the animation window.
  3. 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.png
Before you finish:
  1. Opens standalone — no console errors.
  2. Only
    transform
    /
    opacity
    animate per frame; DevTools shows no per-frame Layout/Paint.
  3. Screenshotted at start / mid / end — correct, no
    scaleX
    text distortion, FLIP lands true.
  4. Holds 60fps;
    will-change
    applied just-in-time and removed when idle (no leftover layers).
  5. prefers-reduced-motion
    honored.
打包工具
scripts/
目录):
scripts/seek-shot.sh anim.html 0 1.5 3
会冻结
?t=N
测试环境并截取每个时刻的截图;
scripts/contact-sheet.sh sheet.png frame-*.png
会将截图拼接成一张预览图,便于快速查看。详见
scripts/README.md
交付物为单个独立的
.html
文件
,可直接在浏览器中打开 —— 包含标记语言、CSS/JS动画,以及冻结测试环境的代码。对于此技能,验证分为两部分:帧视觉效果正确 渲染成本低(仅合成器处理)。
输出规范:
  • 单个
    .html
    文件,依赖项可通过CDN引入,动画由CSS过渡/
    @keyframes
    或Web Animations API驱动。
  • 包含冻结机制,确保截图能捕获确定的帧:
    • CSS
      @keyframes
      ?t=N
      设置
      el.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused'
    • WAAPI / JS → 保留动画对象并执行
      anim.pause(); anim.currentTime = N*1000
验证流程 —— 冻结 → 截图,然后分析卡顿情况:
  1. 在起始/中间/结束帧打开无头浏览器(
    ?t=0
    、中间值、结束值),分别截图;确认动画视觉效果正确(
    scaleX
    未导致文字裁剪/拉伸、FLIP最终处于真实布局、阴影/高度过渡效果正常)。
  2. 确认仅使用合成器 —— 这是此技能的核心目标。可通过以下两种方式验证:
    • DevTools → Performance:录制动画,检查每帧是否**没有紫色“Layout”或绿色“Paint”**条带(仅显示“Composite Layers”)。
    • 或无头追踪:使用
      npx playwright screenshot
      获取视觉截图,同时捕获CDP/
      tracing
      记录,断言动画期间未触发
      Layout
      /
      Paint
      事件。
  3. 观察FPS/“Frame Rendering Stats” overlay保持在60 —— 丢帧意味着引入了高开销属性。
bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png
完成前检查:
  1. 可独立打开 —— 无控制台错误。
  2. 每帧仅对
    transform
    /
    opacity
    执行动画;DevTools显示每帧无Layout/Paint操作。
  3. 在起始/中间/结束帧截图 —— 效果正确,无
    scaleX
    文字变形,FLIP最终位置准确。
  4. 保持60fps;
    will-change
    仅在需要时应用,空闲时移除(无残留图层)。
  5. 遵循
    prefers-reduced-motion
    设置。

Quick reference

速查指南

GoalDo this
Move element
transform: translate()
Resize without distortionFLIP with
transform
Shadow on hoverAnimate
opacity
of shadow pseudo-element
Expand to content heightgrid
0fr→1fr
, or
interpolate-size: allow-keywords
Many elements movingBatch
getBoundingClientRect
reads, then writes
Smooth animation start
will-change
just-in-time, remove when idle
Verify it's compositor-onlyDevTools Performance: no purple "Layout"/green "Paint" per frame
目标实现方式
移动元素
transform: translate()
无变形调整尺寸搭配
transform
使用FLIP
悬停阴影效果对阴影伪元素执行
opacity
动画
展开至内容高度grid
0fr→1fr
,或
interpolate-size: allow-keywords
多元素移动批量执行
getBoundingClientRect
读取,再批量写入
平滑启动动画仅在需要时应用
will-change
,空闲时移除
验证仅使用合成器DevTools Performance:每帧无紫色“Layout”/绿色“Paint”条带

Gotchas

注意事项

  • scaleX/scaleY
    stretches text and children; use FLIP when content must stay crisp.
  • transform
    percentages are relative to the element's own box, not the parent — different from
    left: %
    .
  • Overusing
    will-change
    or
    translateZ(0)
    creates too many layers and hurts performance; promote only what animates.
  • filter
    and
    backdrop-filter
    are compositor-related but still expensive; animate their presence via opacity cross-fades rather than animating the blur radius.
  • The grid
    1fr→0fr
    content must have
    overflow: hidden
    and
    min-height: 0
    or it won't collapse.
  • Always gate non-essential motion behind
    @media (prefers-reduced-motion: reduce)
    .
  • scaleX/scaleY
    会拉伸文字和子元素;如需内容保持清晰,请使用FLIP。
  • transform
    的百分比基于元素自身的盒子,而非父元素 —— 与
    left: %
    不同。
  • 过度使用
    will-change
    translateZ(0)
    会创建过多图层,反而降低性能;仅提升需要动画的元素。
  • filter
    backdrop-filter
    与合成器相关,但开销仍较高;通过透明度交叉淡入淡出控制其显示,而非动画模糊半径。
  • grid
    1fr→0fr
    方案中的内容必须设置
    overflow: hidden
    min-height: 0
    ,否则无法收起。
  • 务必通过
    @media (prefers-reduced-motion: reduce)
    控制非必要动画。

Reference files

参考文件

  • references/patterns-and-profiling.md
    — 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性能分析指南(确认仅合成器帧)、简化动画方案,以及属性开销速查表。