Skeleton 與 Shimmer 載入效果:減少元件載入時的版面跳動
最近剛好遇到一個元件載入時間比較久的情況。畫面空著會讓人懷疑是不是卡住了,但直接放轉圈圈,又看不出接下來會出現什麼;等內容完成後突然把下方區塊往下推,版面也會明顯跳動。
最後我採用的是 Skeleton 搭配 Shimmer:先用接近真實內容尺寸的灰色區塊保留空間,再讓一條亮光從左往右掃過。這裡真正影響版面穩定的是 Skeleton 的尺寸,Shimmer 則負責告訴使用者「內容還在載入」。
Shimmer 是什麼?和 Skeleton 有什麼關係?
Skeleton 是模擬最終內容結構的佔位畫面,Shimmer 則是加在佔位畫面上的微光掃過動畫。兩者經常一起出現,但解決的是不同問題。
████░░████████ → 亮光掃過| 載入方式 | 呈現方式 | 適合情境 |
|---|---|---|
| Skeleton | 用灰色區塊模擬內容結構 | 卡片、列表、圖片與文字混合內容 |
| Shimmer | 在 Skeleton 上移動亮光 | 需要更明確地表示內容仍在載入 |
| Pulse | 整個 Skeleton 淡入淡出 | 想保留較安靜的視覺效果 |
| Loading Spinner | 顯示轉圈圖示 | 無法預測內容版型或範圍很小的操作 |
可以把它們簡單理解成:
Skeleton = 骨架本體Shimmer = 骨架上掃過的動畫如果目標是避免內容載入後造成版面位移,只加入 Shimmer 並沒有用。Skeleton 必須先占住接近最終元件的寬度與高度,瀏覽器才不需要在資料完成後重新挪動周圍內容。web.dev 的 CLS 優化文件也把 placeholder 或 Skeleton UI 列為預留動態內容空間的方法之一。
用 CSS 做出基本 Shimmer 效果
先建立一個最小的 Skeleton 元素:
<div class="skeleton" aria-hidden="true"></div>.skeleton { position: relative; overflow: hidden; width: 300px; height: 20px; background: #e5e7eb; border-radius: 6px;}
.skeleton::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient( 90deg, transparent, rgb(255 255 255 / 60%), transparent ); animation: shimmer 1.5s infinite;}
@keyframes shimmer { 100% { transform: translateX(100%); }}這段 CSS 分成四個部分:
.skeleton提供灰色底色與固定尺寸。::after在骨架上建立一層漸層亮光。translateX()把亮光從元素左側移到右側。overflow: hidden把超出骨架範圍的亮光裁掉。
aria-hidden="true" 則是避免螢幕閱讀器把純裝飾的佔位元素當成內容逐一讀出。實際載入狀態可以放在外層區域的 aria-busy,而不是替每一條灰色區塊加上說明。
Tailwind CSS 有直接可用的 Skeleton class 嗎?
有,但要先分清楚動畫類型。Tailwind CSS 內建的 animate-pulse 會讓元素整體淡入淡出,官方文件也直接把它用在 Skeleton loader。這是 Pulse,不是亮光由左往右掃過的 Shimmer。
如果只需要簡單、比較安靜的載入效果,可以直接寫:
<div class="flex max-w-sm space-x-4 rounded-md p-4 motion-safe:animate-pulse" aria-hidden="true"> <div class="h-10 w-10 rounded-full bg-slate-200 dark:bg-slate-700"></div>
<div class="flex-1 space-y-3 py-1"> <div class="h-2 w-2/3 rounded bg-slate-200 dark:bg-slate-700"></div> <div class="h-2 rounded bg-slate-200 dark:bg-slate-700"></div> <div class="h-2 w-4/5 rounded bg-slate-200 dark:bg-slate-700"></div> </div></div>motion-safe:animate-pulse 表示只有在使用者沒有要求減少動態效果時才播放動畫。Skeleton 的尺寸與底色仍然會保留,不需要另外寫 media query。
Tailwind 官方內建動畫清單沒有 animate-shimmer。以這個專案使用的 Tailwind CSS 3.4 為例,要先在 tailwind.config.cjs 加入自訂動畫:
/** @type {import('tailwindcss').Config} */module.exports = { theme: { extend: { keyframes: { shimmer: { "0%": { transform: "translateX(-100%)" }, "100%": { transform: "translateX(100%)" }, }, }, animation: { shimmer: "shimmer 1.5s linear infinite", }, }, },};設定完成後,就能使用自己定義的 animate-shimmer:
<div class="relative h-5 w-72 overflow-hidden rounded-md bg-slate-200 dark:bg-slate-700" aria-hidden="true"> <div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/60 to-transparent motion-safe:animate-shimmer motion-reduce:hidden" ></div></div>所以最短的答案是:想快速做 Skeleton,可以直接用 animate-pulse;想要真正的 Shimmer,則要自訂 animate-shimmer。Tailwind CSS 4 仍保留 animate-pulse,但自訂主題動畫的設定方式已改成 CSS 的 @theme,不要直接照搬上面的 v3 設定檔。
如果專案原本就有 Tailwind,而且不要求一定要看到微光掃過,我會直接選 animate-pulse。它能少維護一組偽元素、漸層與 keyframes,卡片尺寸也都能用既有 utility class 完成。反過來說,如果專案沒有使用 Tailwind,不需要只為了一個載入動畫多加整套依賴;前面的原生 CSS 已經足夠。可以依現況這樣選:
- 已使用 Tailwind,只需要清楚的載入回饋:直接用
animate-pulse。 - 已使用 Tailwind,而且設計指定 Shimmer:自訂
animate-shimmer。 - 沒有使用 Tailwind:保留原生 CSS,不必為這個效果額外導入框架。
卡片 Skeleton 要盡量貼近真實版型
假設完成後的元件是一張包含頭像、標題與兩行文字的卡片,Skeleton 也應該保留相同的排列方式。
<article class="card-skeleton" aria-hidden="true"> <div class="skeleton avatar"></div>
<div class="content"> <div class="skeleton title"></div> <div class="skeleton text"></div> <div class="skeleton text short"></div> </div></article>.card-skeleton { display: flex; gap: 16px; min-height: 96px; padding: 16px;}
.avatar { width: 64px; height: 64px; border-radius: 50%; flex-shrink: 0;}
.content { flex: 1;}
.title { width: 45%; height: 20px; margin-bottom: 12px;}
.text { width: 100%; height: 14px; margin-bottom: 8px;}
.text.short { width: 70%;}重點不是把每個字的長度猜得一模一樣,而是讓外層高度、圖片比例、內距與主要欄位的位置接近最終內容。若卡片包含圖片,也可以用 width、height 或 CSS aspect-ratio 預留比例;相關原理可參考CSS 圖片比例控制:aspect-ratio vs Padding Hack。
Vue 3 中延遲顯示 Skeleton
如果 API 在 100ms 左右就回應,Skeleton 才剛出現便被換掉,短暫閃爍反而會讓畫面感覺更不穩。因此我會延遲約 200ms,只有請求持續超過這段時間才顯示 Skeleton。
<script setup lang="ts">import { onMounted, ref } from "vue";
type Product = { id: number; name: string; description: string;};
const product = ref<Product | null>(null);const loading = ref(true);const showSkeleton = ref(false);const errorMessage = ref("");
async function fetchProduct() { const skeletonTimer = window.setTimeout(() => { showSkeleton.value = true; }, 200);
try { const response = await fetch("/api/products/1");
if (!response.ok) { throw new Error(`HTTP ${response.status}`); }
product.value = await response.json(); } catch { errorMessage.value = "商品載入失敗,請稍後再試。"; } finally { window.clearTimeout(skeletonTimer); showSkeleton.value = false; loading.value = false; }}
onMounted(fetchProduct);</script>
<template> <section class="product-region" :aria-busy="loading"> <ProductSkeleton v-if="showSkeleton" /> <ProductCard v-else-if="product" :product="product" /> <p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p> </section></template>
<style scoped>.product-region { min-height: 160px;}</style>這裡的 .product-region 仍然設定了 min-height。因為前 200ms 不會顯示 Skeleton,如果外層也完全沒有預留高度,真實內容出現時還是可能推動下方版面。
200ms 不是所有專案都適用的固定答案。可以依實際請求時間與畫面切換感受調整,但原則不變:快速完成時不要讓載入提示閃一下,載入較久時則要提供明確回饋。
Shimmer、Pulse 與 Spinner 怎麼選?
我會依「載入後會出現什麼」來判斷,而不是所有等待狀態都使用 Skeleton。
- 商品列表、文章卡片、社群動態:內容結構可預測,適合 Skeleton;需要更明顯回饋時再加 Shimmer。
- 畫面上有大量佔位區塊:Pulse 或靜態 Skeleton 比較不會讓多條亮光同時移動。
- 按鈕送出、儲存或刪除:使用按鈕內的 loading 狀態,保留按鈕原本寬度,避免整頁換成 Skeleton。
- 無法預測完成後的內容結構:Spinner 或簡短狀態文字會更直接。
如果載入完成後是既有元素的位置或尺寸發生變化,而不是第一次載入內容,Skeleton 也不一定適合。這類情況可以另外評估 FLIP 動畫優化技術,讓既有元素的版面變化以轉場方式呈現。
記得處理減少動態效果設定
Shimmer 是持續移動的裝飾動畫。當使用者在作業系統開啟「減少動態效果」時,可以透過 prefers-reduced-motion 停止動畫,只保留靜態 Skeleton。
@media (prefers-reduced-motion: reduce) { .skeleton::after { animation: none; }}MDN 將 prefers-reduced-motion 定義為偵測使用者是否希望減少非必要動態效果的媒體查詢。停掉 Shimmer 不會影響載入流程,灰色骨架仍然能保留空間並表達內容尚未完成。
我最後保留的判斷原則
加入載入效果時,我會先確認版面是否穩定,再決定動畫要多顯眼:
- 先用接近真實內容的 Skeleton 預留空間。
- 專案已有 Tailwind、又不要求 Shimmer 時,直接用
animate-pulse。 - 短請求延遲顯示 Skeleton,避免一閃而過。
- 延遲期間仍由外層容器保留必要高度。
- 透過
prefers-reduced-motion關閉非必要動畫。
真正減少版面跳動的是正確的佔位尺寸,不是亮光動畫本身。Shimmer 應該是輔助回饋,而不是用來掩蓋沒有預留空間的版面。
常見問題
Q: Skeleton 一定能降低 CLS 嗎?
A: 不一定。Skeleton 必須在真實內容載入前就占住空間,而且尺寸要接近最後呈現的元件。如果 Skeleton 比內容矮很多、延遲期間沒有預留空間,或替換時連外層尺寸也一起改變,畫面仍可能發生版面位移。Shimmer 動畫本身不會替內容保留空間。
Q: 為什麼 Skeleton 會在畫面上閃一下?
A: 常見原因是請求很快完成,但程式仍在請求開始時立即顯示 Skeleton。可以延遲約 150~300ms 再顯示,並在請求完成時清除計時器。這段數值應依專案的實際請求時間調整,延遲期間也要由外層容器保留必要尺寸。
參考資料:
回報錯字、失效連結,或告訴我你想看的延伸主題。