Communitygithub.com

yokyznt/60fps-動畫

當使用者提到「修復卡頓的 CSS 動畫」、「讓動畫達到 60fps」、「停止佈局抖動」、「平滑動畫寬度/高度/上/左」、「將動畫轉換為 transform」、「高效製作 box-shadow 動畫」、「動畫高度 auto」、「FLIP 動畫」或「為什麼我的滾輪/懸停動畫很卡」時,應使用此技能。

60fps-動畫 是什麼?

這項技能專注於解決網頁動畫卡頓的常見問題,其核心原則是僅使用 `transform` 和 `opacity` 屬性來觸發動畫。瀏覽器渲染流程依序為「佈局 → 繪製 → 合成」,而動畫 `width`、`height`、`top`、`left` 等屬性會強制瀏覽器在每一影格重新計算佈局和繪製,阻塞主線程。相對地,`transform` 和 `opacity` 的動畫完全在合成器(通常由 GPU 處理)上執行,跳過佈局和繪製,從而實現流暢的 60/120fps 動畫。文件詳細說明了如何使用 FLIP 技術來低成本地將佈局變化映射為 `transform` 動畫,如何透過偽元素的 `opacity` 來模擬成本高昂的 `box-shadow` 動畫,以及如何利用 CSS Grid 或 `interpolate-size` 屬性來實現 `height: auto` 的平滑動畫。此外,它也強調了避免「佈局抖動」(Layout Thrashing)的重要性,提倡先批量讀取再批量寫入 DOM 屬性。這項技能適用於 Claude Code、Cursor、Codex 等支援自訂技能的 AI 編碼代理,幫助開發者快速診斷並修復動畫效能瓶頸,最終交付一個可直接執行的獨立 HTML 文件,並提供詳盡的驗證步驟,確保動畫滿足「僅由合成器處理」且「保持在 60fps」的效能標準。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/yokyznt/claude-skills-web-definitiva/tree/HEAD/skills/60fps-animation

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

高效能網頁動畫

消除導致網頁動畫卡頓的首要原因:動畫那些會強制瀏覽器在每一影格重新計算佈局(回流)或重新繪製的屬性。瀏覽器的渲染階段依序為 佈局 → 繪製 → 合成。動畫 width、height、top、left、margin、padding、box-shadow 或 filter 會使每個影格重新執行佈局和/或繪製,從而阻塞主線程。而動畫 transform 和 opacity 則完全在合成器(通常是 GPU)上執行,跳過佈局和繪製,這正是讓動畫在 60/120fps 下保持流暢的原因。

使用時機

當動畫出現卡頓或掉幀(丟失影格)時,當懸停/滾動效果感覺沉重時,當動畫元素的大小/位置/陰影時,當佈局變化需要平滑動畫(如卡片重新排序、元素在容器間移動)時,當動畫 height: auto 時,或在審查動畫程式碼的效能時使用。

核心規則:僅動畫 transform 和 opacity

將每個「昂貴」的動畫映射為一個等效的低成本動畫。

正在動畫(昂貴)觸發的階段替換為(低成本)
width / height佈局 + 繪製transform: scaleX()/scaleY() (若需真實大小則搭配 FLIP)
top / left / margin佈局 + 繪製transform: translate()
box-shadow繪製動畫一個持有陰影的偽元素的 opacity
filter: blur()繪製(負擔重)透過 opacity 交叉淡入淡出兩個圖層,或謹慎接受
background-position繪製動畫子圖層的 transform: translate()
color / background繪製通常可接受;或交叉淡入淡出圖層

透過 transform 實現位置和大小

/* 不好:每影格都動畫佈局 */
.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。

透過偽元素透明度實現低成本 box-shadow

動畫 box-shadow 會每影格重新繪製一個大面積的模糊區域。相反地,應將陰影預先繪製在 ::after 上,然後僅動畫其 opacity。

.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:低成本地動畫佈局變化

FLIP(First, Last, Invert, Play)僅使用 transform 來動畫佈局變化(重新排序、調整大小、在容器間移動)。測量元素最初的位置(First)和最終將達到的最後位置(Last),應用一個反向的變形,使其在視覺上看似未移動,然後將變形動畫回原始狀態(Play)。DOM 最終處於其真實的最終佈局;而運動過程則完全是合成器的工作。

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() 在合成器上執行變形。對於多個元素,請在任何變異之前批量執行所有 getBoundingClientRect() 讀取(參見下方的佈局抖動)。

動畫 height: auto

height: auto 傳統上無法進行插值。以下為現代和備用方法:

/* 現代方法(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 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; }

這個網格技巧可以動畫軌道大小(對合成器友好且廣泛支援),且無需 JavaScript 測量高度。在目標瀏覽器支援的地方使用 interpolate-size: allow-keywords / calc-size(auto, size);同時將網格技術作為跨瀏覽器的預設方案。

避免佈局抖動(批量讀取,然後寫入)

在寫入佈局屬性之後,讀取一個佈局屬性(offsetWidth、getBoundingClientRect、scrollTop、getComputedStyle)會強制進行同步回流。在迴圈中交錯進行讀取和寫入(「佈局抖動」)可能導致每個影格內執行數十次強制回流。

// 不好:讀取、寫入、讀取、寫入... 每次迭代都強制回流
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:謹慎使用

will-change: transform 會提前將一個元素提升到自己的合成器圖層,從而避免動畫開始時的卡頓。但每個被提升的圖層都會消耗 GPU 記憶體,過度使用會降低效能。

.menu { will-change: transform; }   /* 僅用於即將開始動畫的元素 */

規則:僅在動畫開始前應用(例如在懸停/父元素狀態上),在空閒時移除(設為 will-change: auto),切勿對大量元素無差別應用,也切勿永久性地將其保留在大型/眾多節點上。單獨一個 transform: translateZ(0) 駭客技巧也能達到同樣的提升效果,但更難以撤銷——建議優先使用 will-change。

交付與驗證(獨立 HTML)

打包好的輔助腳本(位於 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 transitions/@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 (影格渲染統計)」覆蓋層保持在 60 — 掉幀表示某個昂貴的屬性又被引入。
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png

在你完成之前:

  1. 獨立打開 — 無控制台錯誤。
  2. 每個影格僅 transform / opacity 在動畫;DevTools 顯示沒有逐影格的佈局/繪製。
  3. 在開始/中間/結束時刻進行了螢幕截圖 — 正確,沒有 scaleX 文字失真,FLIP 準確定位。
  4. 保持在 60fps;will-change 即時應用並在空閒時移除(無殘留圖層)。
  5. 遵循 prefers-reduced-motion。

快速參考

目標這樣做
移動元素transform: translate()
不扭曲地調整大小使用 transform 的 FLIP
懸停時顯示陰影動畫陰影偽元素的 opacity
展開到內容高度grid 0fr→1fr,或 interpolate-size: allow-keywords
大量元素移動批量 getBoundingClientRect 讀取,然後寫入
平滑的動畫開始即時 will-change,空閒時移除
驗證是否僅合成器處理DevTools Performance:每影格無紫色「Layout (佈局)」/綠色「Paint (繪製)」

注意事項

  • scaleX/scaleY 會拉伸文字和子元素;當內容必須清晰時,請使用 FLIP。
  • transform 的百分比是相對於元素自身的盒子,而不是父元素——這與 left: % 不同。
  • 過度使用 will-change 或 translateZ(0) 會建立過多圖層,反而損害效能;僅提升需要動畫的元素。
  • filter 和 backdrop-filter 與合成器相關,但仍然昂貴;應透過不透明度交叉淡入淡出來動畫它們的存在,而不是動畫模糊半徑。
  • 網格 1fr→0fr 的內容必須設定 overflow: hidden 和 min-height: 0,否則它不會折疊。
  • 始終將非必要的動畫限制在 @media (prefers-reduced-motion: reduce) 內部。

參考文件

  • references/patterns-and-profiling.md — 完整的可執行範例(摺疊式選單、重新排序列表、視差滾動)、用於確認合成器僅處理影格的 DevTools 分析演練、減少動畫模式以及屬性成本速查表。

相關技能