跳到主要內容

動畫:只動兩個屬性,尊重使用者設定

為什麼只該動 transform 和 opacity、進場動畫怎麼做才不會讓內容永遠看不到、多圖動畫的三種做法,以及 reduced-motion 的正確處理。

約 7 分鐘 · animation.md

1. 只動 transform 和 opacity

瀏覽器繪製一幀分四步:style → layout → paint → composite

width height top margin → 從 layout 重來(最貴) 改 background color box-shadow → 從 paint 重來(中等) 改 transform opacity → 只有 composite,可以交給 GPU(最便宜)

所以:

想做 不要
移動 left / top transform: translate()
縮放 width / height transform: scale()
淡入 display opacity
摺疊 height: auto grid-template-rows: 0fr → 1fr

最後一項的技巧值得記:

.collapsible { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 300ms; }
.collapsible[data-open] { grid-template-rows: 1fr; }
.collapsible > div { overflow: hidden; }

這是唯一能對「未知高度」做平滑動畫、又不需要 JS 量高度的方法。

2. 進場動畫的致命錯誤

.reveal { opacity: 0; transform: translateY(20px); }
.reveal.visible { opacity: 1; transform: none; }

如果 JS 沒載入、IntersectionObserver 沒觸發、或使用者的瀏覽器擋了腳本 —— 整頁內容永遠是隱形的

搜尋引擎的爬蟲通常看得到(它讀 DOM),但真人看不到。這種頁面在網路慢的時候會變成一片空白。

三個安全做法:

一、只有 JS 就緒時才隱藏內容

/* 預設可見。<html> 加上 js class 之後才啟用動畫 */
.js .reveal { opacity: 0; transform: translateY(20px); }
.js .reveal[data-visible] { opacity: 1; transform: none; }

二、用 CSS 的 scroll-driven animation(不需要 JS)

@supports (animation-timeline: view()) {
  .reveal {
    animation: fade-up linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 30%;
  }
}

三、加保險絲:無論如何,N 秒後強制顯示。

3. 時間與曲線

動作 時間
hover 變色 100–150ms
小元素出現 / 消失 150–250ms
彈窗、抽屜 250–400ms
頁面轉場 300–500ms

超過 500ms 的介面動畫,使用者會開始覺得慢。

曲線:進場用 ease-out(快進慢停),離場用 ease-in(慢起快走)ease-in-out 用在會回到原位的東西上。

線性只給進度條、載入動畫、旋轉。

4. reduced-motion

有些人看到視差和大幅位移會頭暈。這不是偏好,是前庭障礙。

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    scroll-behavior: auto !important;
  }
}

1ms 而不是 0s:有些程式碼依賴 transitionend 事件,0s 不會觸發它,於是流程卡住。

「減少」不等於「全部拿掉」。 位移與縮放要拿掉,淡入淡出通常可以留 —— 它不會造成不適,而且能保留狀態變化的可讀性。

5. 多張圖片的動畫

做法一:CSS 交叉淡入(2–5 張,最簡單)

.slide { position: absolute; inset: 0; opacity: 0; animation: cycle 12s infinite; }
.slide:nth-child(2) { animation-delay: 4s; }
.slide:nth-child(3) { animation-delay: 8s; }

@keyframes cycle {
  0%, 28% { opacity: 1; }
  33%, 100% { opacity: 0; }
}

第一張要 loading="eager"fetchpriority="high",其他 loading="lazy"

做法二:sprite sheet(逐格動畫)

多張圖合成一張,用 background-positionsteps()

.sprite {
  width: 200px; height: 200px;
  background: url(sprite.webp) 0 0 / 2400px 200px;
  animation: play 1s steps(12) infinite;
}
@keyframes play { to { background-position: -2400px 0; } }

一個請求、沒有 JS、不會有圖片載入順序問題。適合 12–24 格的短循環。

做法三:捲動驅動的序列(產品展示)

不要用 <img> 換 src(會閃)。用 canvas 預載所有幀再繪製:

const images = await Promise.all(urls.map(loadImage));
// 捲動時
const frame = Math.floor(progress * (images.length - 1));
ctx.drawImage(images[frame], 0, 0);

成本很高(60 幀 × 100KB = 6MB),只有在那是產品的核心賣點時才值得,而且一定要有 reduced-motion 的靜態 fallback。

6. 效能守則

  • 一次動的元素不要超過十幾個
  • 動畫元素加 will-change: transform動完要拿掉will-change 常駐會吃記憶體)
  • 不要對 box-shadow 做動畫 —— 改用一個疊在上面、只改 opacity 的假陰影層
  • 捲動監聽一律 { passive: true }
  • 用 IntersectionObserver,不要在 scroll 事件裡算位置
/* 便宜的陰影動畫 */
.card { position: relative; }
.card::after {
  content: ''; position: absolute; inset: 0; border-radius: inherit;
  box-shadow: var(--ds-shadow-3);
  opacity: 0; transition: opacity 200ms;
  pointer-events: none;
}
.card:hover::after { opacity: 1; }

7. 頁面轉場

現代做法是 View Transitions API:

if (document.startViewTransition) {
  document.startViewTransition(() => updateDOM());
}

一定要做特性偵測,不支援時就直接更新 —— 沒有轉場動畫不是錯誤。

8. 檢查清單

  • 只對 transformopacity 做動畫
  • 進場動畫在 JS 失效時內容仍然看得到
  • 時間在 100–400ms 之間
  • prefers-reduced-motion 處理,且用 1ms 不是 0s
  • will-change 用完會移除
  • 捲動監聽是 passive,或改用 IntersectionObserver
  • 多圖動畫的第一張優先載入,其餘 lazy
  • 沒有對 box-shadow 或 filter 做長時間動畫

顯示設定

這裡改的每一項,會即時套用到站上所有預覽。

風格

密度

圓角

動態

系統層級的「減少動態效果」永遠優先於這裡的設定。

語言