動畫:只動兩個屬性,尊重使用者設定
為什麼只該動 transform 和 opacity、進場動畫怎麼做才不會讓內容永遠看不到、多圖動畫的三種做法,以及 reduced-motion 的正確處理。
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-position 加 steps():
.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. 檢查清單
- 只對
transform與opacity做動畫 - 進場動畫在 JS 失效時內容仍然看得到
- 時間在 100–400ms 之間
- 有
prefers-reduced-motion處理,且用 1ms 不是 0s -
will-change用完會移除 - 捲動監聽是 passive,或改用 IntersectionObserver
- 多圖動畫的第一張優先載入,其餘 lazy
- 沒有對 box-shadow 或 filter 做長時間動畫