2354 字
12 分鐘

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 分成四個部分:

  1. .skeleton 提供灰色底色與固定尺寸。
  2. ::after 在骨架上建立一層漸層亮光。
  3. translateX() 把亮光從元素左側移到右側。
  4. 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%;
}

重點不是把每個字的長度猜得一模一樣,而是讓外層高度、圖片比例、內距與主要欄位的位置接近最終內容。若卡片包含圖片,也可以用 widthheight 或 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 不會影響載入流程,灰色骨架仍然能保留空間並表達內容尚未完成。

我最後保留的判斷原則#

加入載入效果時,我會先確認版面是否穩定,再決定動畫要多顯眼:

  1. 先用接近真實內容的 Skeleton 預留空間。
  2. 專案已有 Tailwind、又不要求 Shimmer 時,直接用 animate-pulse
  3. 短請求延遲顯示 Skeleton,避免一閃而過。
  4. 延遲期間仍由外層容器保留必要高度。
  5. 透過 prefers-reduced-motion 關閉非必要動畫。

真正減少版面跳動的是正確的佔位尺寸,不是亮光動畫本身。Shimmer 應該是輔助回饋,而不是用來掩蓋沒有預留空間的版面。

常見問題#

Q: Skeleton 一定能降低 CLS 嗎?#

A: 不一定。Skeleton 必須在真實內容載入前就占住空間,而且尺寸要接近最後呈現的元件。如果 Skeleton 比內容矮很多、延遲期間沒有預留空間,或替換時連外層尺寸也一起改變,畫面仍可能發生版面位移。Shimmer 動畫本身不會替內容保留空間。

Q: 為什麼 Skeleton 會在畫面上閃一下?#

A: 常見原因是請求很快完成,但程式仍在請求開始時立即顯示 Skeleton。可以延遲約 150~300ms 再顯示,並在請求完成時清除計時器。這段數值應依專案的實際請求時間調整,延遲期間也要由外層容器保留必要尺寸。

參考資料:

web.dev:Optimize Cumulative Layout Shift

MDN:prefers-reduced-motion

MDN:translateX()

Tailwind CSS v3.4:Animation

Tailwind CSS:Animation

Skeleton 與 Shimmer 載入效果:減少元件載入時的版面跳動
https://laplusda.com/posts/skeleton-shimmer-loading-layout-shift/
作者
Zero
發佈於
2026-07-22
許可協議
CC BY-NC-SA 4.0
這篇文章有幫助嗎?

回報錯字、失效連結,或告訴我你想看的延伸主題。