iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Modern Web

從實務需求出發:前端視覺與 UI 互動開發實踐系列 第 21 篇

【Day 21】動畫實作:掀起釘選的紙片

  • 分享至 

  • xImage
  •  

昨天我們了解了動畫的效能代價,今天正式進入實作篇。

之前專案做過一個令人印象深刻的動態效果。設計稿是一個佈告欄上有幾張被膠帶貼住的紙片,原本只是靜態。專案的整體風格本來就有不少動畫。再加上佈告欄的紙片,其實是某個核心流程的起點選項,於是就提議加上動態,讓畫面更吸睛。

今天就來拆解這個動態效果,把前幾天學的 transform、translate、@keyframes、animation 用好用滿。那我們開始吧!


一、描述動畫效果

畫面像一塊佈告欄:兩張紙片各被一條膠帶貼在上緣中央。整個互動分成:

  1. 進場:紙片依序由下而上淡入,上方中心點貼上膠帶。膠帶貼上時,會有一個「刷」過去的閃光。
  2. hover:紙片從膠帶處被輕輕掀起,下方陰影稍微明顯。

動畫之前,先把靜止畫面做出來。這次有一點要先留意:紙片與膠帶的邊緣都是不規則圖形。紙片直接用去背的圖片當底圖,膠帶的鋸齒邊緣則用 mask 切出來。

基礎 HTML 與 CSS(不含動畫)

<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`。

到這裡,畫面是兩張貼好膠帶、靜止不動的紙片。

https://ithelp.ithome.com.tw/upload/images/20261005/20184345sTJzBBIwbS.png


二、實作

1. 進場:紙片依序由下而上淡入,再貼上膠帶

進場有三個動作,順序是:紙片上移淡入 → 膠帶「刷」一下貼上 → 閃光掃過。

這些動畫都掛在 .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;
}

2. hover:膠帶端固定,掀起紙張

為了讓動畫像是從佈告欄上掀起黏在上面的紙張,要用 transform-origin 改變 transform 中心點到卡片上緣中央。然後是重點 transform 的設定:

  1. perspective():設定「觀察者離畫面 1000px 遠」,來增加 3D 透視感。一定要寫在最前面。
  2. rotateX(0.5deg):水平的 X 軸旋轉 0.5 度,看起來像是紙片下緣微微朝你靠近。
  3. rotateZ(0.5deg):在平面上順時針轉 0.5 度,很輕微的歪斜,增加真實感。
  4. 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 圖層變形。這樣做有兩個好處:

  • 文字不會被旋轉變糊:文字如果放在變形的元素裡,會跟著一起被 3D 旋轉與縮放,被帶到小數位置的像素上,邊緣就會變糊。拆出去之後,文字不再被重新取樣,實際看起來也確實清楚許多。
  • hover 範圍不會跟著變:觸發範圍是不動的 .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; } /* 不播閃光 */
}

完整範例

CodePen 連結

demo gif

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


參考資料


上一篇
【Day 20】動起來:網頁效能,卡卡 OUT!
下一篇
【Day 22】動畫實作:用 perspective 製作 CSS 3D 翻牌效果
系列文
從實務需求出發:前端視覺與 UI 互動開發實踐 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言