昨天我們了解了動畫的效能代價,今天正式進入實作篇。
之前專案做過一個令人印象深刻的動態效果。設計稿是一個佈告欄上有幾張被膠帶貼住的紙片,原本只是靜態。專案的整體風格本來就有不少動畫。再加上佈告欄的紙片,其實是某個核心流程的起點選項,於是就提議加上動態,讓畫面更吸睛。
今天就來拆解這個動態效果,把前幾天學的 transform、translate、@keyframes、animation 用好用滿。那我們開始吧!
畫面像一塊佈告欄:兩張紙片各被一條膠帶貼在上緣中央。整個互動分成:
動畫之前,先把靜止畫面做出來。這次有一點要先留意:紙片與膠帶的邊緣都是不規則圖形。紙片直接用去背的圖片當底圖,膠帶的鋸齒邊緣則用 mask 切出來。
<div class="board">
<button type="button" class="card">
<span class="paper"></span>
<strong class="title">選項一</strong>
</button>
<button type="button" class="card">
<span class="paper"></span>
<strong class="title">選項二</strong>
</button>
</div>
.card 是整張卡片的外框:負責尺寸、文字置中,以及 hover 的觸發範圍。.paper 是紙片圖層:背景圖、膠帶、hover 的變形與陰影都在這一層。文字刻意不放進 .paper,而是和它並排,原因等到 hover 那段再說明。.paper 的 ::before(膠帶)與 ::after(閃光)疊在上緣中央。CSS 部分只挑重要的地方。首先是紙片:不規則的邊緣與捲角,直接畫在一張去背的 PNG 圖片裡,用 background 鋪滿整張紙片就好,不需要額外裁切:
.card {
/* 用 --tape 換膠帶顏色 */
--tape: rgba(196, 150, 235, 0.85);
position: relative;
display: flex;
justify-content: center;
align-items: center;
aspect-ratio: 450 / 538;
}
.card:nth-child(2n) {
--tape: rgba(120, 190, 150, 0.85);
}
.paper {
position: absolute;
inset: 0;
/* 紙張圖片:450 × 538,邊緣是透明的 */
background: url("https://duk.tw/xPvb8b.png") center / 100% 100% no-repeat;
}
.title {
position: relative; /* 讓文字疊在絕對定位的 .paper 上方 */
}
aspect-ratio: 450 / 538 是用來固定容器的長寬比,讓它等同於紙張圖片的長寬比,圖片才不會被拉歪。center / 100% 100% no-repeat:讓圖片剛好拉伸到整張紙片的大小。然後是膠帶,也就是 .card::before。膠帶要用 absolute 定位在卡片上緣中央,兩端的鋸齒邊緣則用 mask 切出來,形狀是一小段內嵌的 SVG:
.card::before,
.card::after {
--tape-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 56'%3E%3Cpath d='M4 4L12 8L4 14L10 20L3 26L10 32L4 38L12 44L5 52L150 52L156 44L148 38L157 32L149 26L156 20L148 14L156 8L150 4Z'/%3E%3C/svg%3E");
content: "";
position: absolute;
z-index: 2;
top: 5%;
left: 50%;
transform: translate(-50%, -50%);
mask: var(--tape-shape) center / 100% 100% no-repeat;
-webkit-mask: var(--tape-shape) center / 100% 100% no-repeat;
}
.card::before {
background: var(--tape);
}
💡 這裡的膠帶原本是一個方形色塊,套上鋸齒形狀的
mask 後,兩端就有了撕過的毛邊。而膠帶的閃光是一樣的外框,所以一起寫.card::after`。
到這裡,畫面是兩張貼好膠帶、靜止不動的紙片。

進場有三個動作,順序是:紙片上移淡入 → 膠帶「刷」一下貼上 → 閃光掃過。
這些動畫都掛在 .board.show 底下。show 只是用來觸發進場動畫的 class,本身沒有任何樣式:要在畫面準備好之後(例如頁面載入、視窗開啟),才用 JavaScript 幫 .board 加上它,動畫就會開始播放。
<div class="board show">…</div>
.board.show .card {
animation: paperEnter 0.5s ease-out var(--offset, 0s) backwards;
}
@keyframes paperEnter {
/* 只寫了 0%,沒寫 100%,表示「終點沿用元素原本的樣式」 */
0% {
opacity: 0;
translate: 0 28px;
}
}
這裡用獨立的 translate,不寫 transform: translateY()。進場動畫改的是 opacity 與 translate,hover 改的是 transform 與 filter,兩邊屬性不重疊,就不會互相覆蓋。
兩張卡的差別只在 --offset,第二張晚 0.35 秒出現,看起來就是「一張接一張」:
<button class="card" style="--offset: 0s">…</button>
<button class="card" style="--offset: 0.35s">…</button>
值得注意的是卡片動畫還用到了 backwards,為什麼不是用常見的 forwards?主要是因為卡片有 animation-delay,影響了填充模式:
forwards:延遲期間不會套用 0%,紙片會先以完整的樣子顯示,等 0.35 秒後才突然變透明、再淡入,畫面會閃一下。backwards:延遲期間就先套用 0%(紙片先藏起來、等著進場),播完再把控制權還給元素原本的樣式。簡單來說,就是動畫結束要留在最後一格,用 forwards;開始前(延遲期間)要先套用起點的樣子,用 backwards。
等紙片到位,膠帶再貼上去。做法是用 clip-path: inset() 把膠帶一開始藏起來,再動畫到完全顯示:
.card {
/* 基礎延遲 + 個別微調;0.6s 是等紙片先進場(0.5s) */
--t: calc(0.6s + var(--offset, 0s));
}
.card::before,
.card::after {
clip-path: inset(0 100% 0 0); /* 右邊裁掉 100%,一開始什麼都看不到 */
}
.board.show .card::before {
animation: stickerApply 0.55s ease-out var(--t) forwards;
}
@keyframes stickerApply {
100% { clip-path: inset(0 0 0 0); }
}
💡
clip-path屬於 Paint,不是 Composite。不過這個動畫只會播一次,還可以接受。
膠帶貼上去之後,再讓它閃一道光。光是一條斜向的白色漸層(前面也用 mask 裁切出鋸齒邊緣),靠移動 background-position 從右掃到左:
.paper::after {
background: linear-gradient(
115deg,
transparent 32%,
rgba(255, 255, 255, 0.78) 50%,
transparent 68%
);
background-size: 220% 100%;
background-position: 140% 0;
opacity: 0;
}
@keyframes stickerShine {
0% { background-position: 140% 0; opacity: 0; }
18% { opacity: 1; }
100% { background-position: -40% 0; opacity: 0; }
}
因為前面也有在閃光用了 clip-path: inset(0 100% 0 0) 隱藏,正好示範同元素掛兩個動畫的寫法,用逗號隔開不同動畫設定。如果沒隱藏,掛 stickerShine 即可。
.board.show .paper::after {
animation:
stickerApply 0.55s ease-out var(--t) forwards,
stickerShine 0.75s ease-out calc(var(--t) + 0.45s) forwards;
}
為了讓動畫像是從佈告欄上掀起黏在上面的紙張,要用 transform-origin 改變 transform 中心點到卡片上緣中央。然後是重點 transform 的設定:
perspective():設定「觀察者離畫面 1000px 遠」,來增加 3D 透視感。一定要寫在最前面。rotateX(0.5deg):水平的 X 軸旋轉 0.5 度,看起來像是紙片下緣微微朝你靠近。rotateZ(0.5deg):在平面上順時針轉 0.5 度,很輕微的歪斜,增加真實感。scaleY(0.98):垂直方向縮到 98%,透視會讓抬起的下緣看起來變長,所以縮一點作補償。最後因為是 hover 狀態下觸發,所以在 transition 設定要漸變的屬性。
.paper {
transform-origin: 50% 0; /* 以上緣中央為軸 */
transition:
transform 0.35s ease-in-out,
filter 0.35s ease-in-out;
}
.card:hover .paper,
.card:active .paper {
transform: perspective(1000px) rotateX(0.5deg) rotateZ(0.5deg) scaleY(0.98);
filter: drop-shadow(0 18px 22px rgba(62, 40, 18, 0.32));
}
.paper這裡的選擇器是 .card:hover .paper:用不會變形的 .card 來判斷 hover,只讓 .paper 圖層變形。這樣做有兩個好處:
.card,紙片翹起來時就不會因為邊緣移動,讓游標跑出範圍、hover 解除又再觸發,造成抖動。文字不跟著變形,但如果完全不動,紙片掀起時文字會顯得「掉隊」。折衷的做法是讓文字用整數像素的 translate 跟著位移,不旋轉、不縮放:
.title {
transition: translate 0.35s ease-in-out;
}
.card:hover .title,
.card:active .title {
translate: -1px -2px;
}
box-shadow可能有人會好奇為什麼陰影不用 box-shadow?那是因為紙片的邊緣是不規則的。box-shadow 畫的是元素方框的陰影,陰影不會沿著不規則的圖形邊緣跑;filter: drop-shadow() 則是依照「實際畫出來的輪廓」產生陰影。
部分使用者會在系統裡開啟「減少動態效果」,可以用 prefers-reduced-motion 偵測,讓他直接跳過動畫、呈現結果:
@media (prefers-reduced-motion: reduce) {
.paper,
.title { transition: none; }
.board.show .card {
animation: none;
}
.board.show .paper::before,
.board.show .paper::after {
animation: none;
clip-path: inset(0 0 0 0); /* 直接顯示膠帶 */
}
.paper::after { display: none; } /* 不播閃光 */
}

.
.
.
今天稍微提到一點 CSS 3D,明天想繼續延伸這個主題,來研究專案中另一個有趣的動畫效果 —— 翻牌動畫。我們明天見!