iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Modern Web

微光採集瓶:用 Vue 3 打造每日美好記錄網頁系列 第 11 篇

【Day 11】動態互動:讓光珠從瓶口落下的進場動畫

  • 分享至 

  • xImage
  •  

昨天我們成功利用 v-for,把採集到的資料轉化為一顆顆彩色光珠呈現在瓶子裡。但目前點擊按鈕,光珠都是瞬間出現在瓶中,看起來少了一點「被放進瓶子」的互動感。

今天我們要用 CSS @keyframes(關鍵影格),讓新加入的光珠有重量感,使其從瓶子上方自然落入瓶底。

補充:其實 Vue 也有專門處理清單動畫的工具

在動手寫 CSS 之前,先岔題講一件事。

Vue 其實有一個內建元件,叫做 <TransitionGroup>,專門用來處理「清單裡的項目新增、刪除、重新排序」時的動畫效果。聽起來,是不是剛好就是我們今天想解決的問題?

那為什麼今天不用它呢?

<TransitionGroup> 跟純 CSS 動畫最大的差別,在於「離場」這件事。

純 CSS 動畫,靠的是「元素第一次被插入畫面時,瀏覽器自動播放一次動畫」這個動作。這很適合處理「新增」,但沒辦法處理「移除」。如果之後我們要把某顆光珠從陣列裡刪掉,Vue 會直接把對應的畫面元素整個移除,是瞬間消失,沒辦法讓它先播放一個「飛出去」的動畫,再真正離開畫面。

而 <TransitionGroup> 就是為了解決「進場」跟「離場」這兩件事而生的,甚至還能處理「重新排序時,元素要怎麼順暢地移動到新位置」。
不過,今天我們只需要處理「新增」,光珠只會越來越多,還不會有「刪除」的需求,純 CSS 動畫已經夠用了,還不需要引入新工具。

但這個工具先記住,之後會用到。還記得我在前言提到的期望成果嗎?每個月的最後一天,瓶中光珠要像流星一樣,一顆顆飛出瓶身,匯聚成月底的回顧卡片。光珠要真正「離開畫面」、而且要有動畫效果,這正是純 CSS 做不到,但 <TransitionGroup> 就是為了解決的情境。到那天,我們就會使用到這個工具。

但今天先從最單純的下墜動畫開始吧~

一、@keyframes:定義一段「隨時間變化」的動畫

@keyframes 是 CSS 用來定義動畫過程的語法,可以把它想成「幫動畫規劃一份逐格腳本」,告訴瀏覽器,在動畫開始、中間、結束的各個時間點,元素應該長什麼樣子,瀏覽器會自動幫你把這些關鍵點之間的過程,平滑地補完。

寫法長這樣:

@keyframes 動畫名稱 {
  0% {
    /* 動畫剛開始時的樣子 */
  }
  100% {
    /* 動畫結束時的樣子 */
  }
}

0% 代表動畫剛開始,100% 代表動畫結束,中間還可以插入任意百分比(例如 50%)來定義中間過程的樣子。

二、設計光珠的下墜動畫

我想要的效果是:光珠從瓶口(畫面上方)的位置,掉落到它最終該停留的位置,中途還帶一點彈跳感。

@keyframes dropIn {
  0% {
    transform: translateY(-60px);
    opacity: 0;
  }
  60% {
    transform: translateY(4px);
    opacity: 1;
  }
  80% {
    transform: translateY(-2px);
  }
  100% {
    transform: translateY(0);
  }
}

拆解一下這段在做什麼:

  • 0%:光珠一開始在瓶口上方 60px 的位置(translateY(-60px),負值代表往上移動),而且完全透明(opacity: 0),代表「還沒真正出現」
  • 60%:光珠已經掉到比最終位置再往下一點點的地方(translateY(4px),正值代表往下移動),這是模擬「掉下去稍微超過一點」的感覺,此時光珠已經完全不透明
  • 80%:因為剛剛衝過頭,稍微往上彈了一下(translateY(-2px))
  • 100%:最終停在正確的位置(translateY(0))

這個「往下過頭又彈回來」的過程,就是我們想要的「重量感」跟「彈跳感」。

三、把動畫套用到光珠上

回到 src/components/GemJar.vue,找到 .gem-dot 的樣式,加上 animation 屬性:

/* 單顆光珠 */
.gem-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  box-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
  animation: dropIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes dropIn {
  0% {
    transform: translateY(-60px);
    opacity: 0;
  }
  60% {
    transform: translateY(4px);
    opacity: 1;
  }
  80% {
    transform: translateY(-2px);
  }
  100% {
    transform: translateY(0);
  }
}

這裡的 animation 屬性,設定了三件事:

  1. dropIn:要播放哪一段 @keyframes
  2. 0.6s:動畫總長 0.6 秒
  3. cubic-bezier(0.34, 1.56, 0.64, 1):這是一組貝茲曲線參數,決定動畫播放的「速度節奏」

四、貝茲曲線是什麼?為什麼要用這串奇怪的數字?

一般的動畫(例如 ease、linear)速度曲線是平滑遞增或遞減的,沒辦法做出「超過終點、再彈回來」這種效果。但我們剛剛在 @keyframes 裡,已經手動定義了「60% 時衝過頭、80% 時彈回來」這個過程。這時候如果幫它搭配一個「本身帶有一點回彈感」的貝茲曲線(像我們用的這組參數,特徵是中間會有一個超過 1 的數值),可以讓整體動畫的節奏感更自然、更有彈性,而不是死板地按照等速播放。

這組貝茲曲線數字,其實是社群裡常見、被大量測試過的「彈跳感」參數,你不需要自己去手算這些數字。之後如果想調整成不同的手感,可以搜尋 "cubic-bezier bounce",會有很多現成的參數可以直接套用比較。

五、為什麼「只有新的那顆」會播放動畫?

你可能會好奇:animation 是寫在 .gem-dot 這個 class 上,瓶子裡明明已經有好幾顆光珠了,為什麼重新整理畫面、或是新增一顆光珠時,舊的光珠不會跟著重新掉一次?

這是瀏覽器預設的特性:CSS 的 animation,只會在元素「第一次被放入到畫面(DOM)裡」的那個瞬間播放一次。已經存在畫面上的舊光珠,並沒有被重新插入,所以不會觸發動畫;只有這次 v-for 新增出來的那個新元素,才會經歷「第一次被放入」,因此只有它會播放下墜效果。

六、修正問題

目前瀏覽器畫面如下圖所示:
https://ithelp.ithome.com.tw/upload/images/20260925/201780676VxzqN6bTS.png

有什麼問題?

  1. 所有光珠懸浮在瓶子的正中間,沒有落在底部。
  2. 目前,先放進去的光珠出現在最上面,最新放進去的光珠反而跑到了最下面。照理來說,較早放進瓶子的珠子應該要落在底層,後來的珠子再一層層往上堆疊才合理。

怎麼解決?

  1. 光珠沒有落在瓶底

原因:.bottle 預設用了 align-items: center,這會把裝光珠的容器強制垂直置中在半空中。

解法:把 .bottle 改為縱向排列(flex-direction: column),並改用 justify-content: flex-end 把容器推到底部;另外替空瓶文字加上 margin: auto,確保沒有光珠時提示文字依然置中。

.bottle {
  /* 原有寬高、邊框樣式維持不變 */
  display: flex;
  flex-direction: column;   /* 改為縱向排列 */
  justify-content: flex-end; /* 讓光珠們沉到瓶底 */
  align-items: center;
  /* ... */
}

/* 確保空瓶提示文字依然垂直水平置中 */
.bottle > span {
  margin: auto;
}
  1. 最新放入的光珠應該要在最下面
    原因:一般的 flex-wrap: wrap 排版是由上往下排,陣列第 1 筆資料自然會出現在頂部。

解法:不需要特地修改 JavaScript 去翻轉陣列,直接在 .gem-pile 使用 flex-wrap: wrap-reverse(向上反向折行)。這樣第一筆資料就會放在最底層,後面陸續採集的光珠便會一層層向上堆疊。

.gem-pile {
  display: flex;
  flex-wrap: wrap-reverse;  /* 向上折行,先放的光珠在最底層 */
  justify-content: center;
  align-content: flex-start;
  gap: 6px;
  padding: 12px;
  width: 100%;
  box-sizing: border-box;
}

七、成果驗證

https://ithelp.ithome.com.tw/upload/images/20260925/20178067doiaPfvSvq.png

存檔後回到瀏覽器,測試看看:

  1. 選顏色、寫文字,按下「放入這道微光」,確認新的光珠是不是從上方掉下來、輕輕彈一下,才停在瓶底?
  2. 多放幾顆光珠,確認已經在瓶子裡的舊光珠,是不是維持不動,只有新的那顆會播放下墜動畫?
  3. 確認光珠們是否都有沉在瓶底,而且先放進去的光珠留在最下層、越後面放的越往上堆疊?
  4. 試著調整 @keyframes 裡的數值(例如把 60px 改成 100px),看看下墜的高度、彈跳的幅度會怎麼變化,感受一下這些數字跟畫面效果之間的關聯。

今日小結

  • @keyframes:學會定義一段隨時間變化的動畫過程,用百分比標記關鍵時間點的樣式。
  • 貝茲曲線(cubic-bezier):理解動畫的「節奏曲線」怎麼影響播放的手感,搭配手動設計的關鍵影格,做出彈跳感。
  • CSS 動畫在列表渲染中的特性:只有「新插入」的元素才會播放進場動畫,不需要額外的 JavaScript 邏輯控制。
  • 技術選型的判斷:認識了 <TransitionGroup> 這個更強大的工具,並理解為什麼現在還不需要用到它。

明日預告

光珠現在會帶著重量感掉進瓶子裡了!不過如果你多放幾顆光珠,可能會發現每一排都是排成一直線,看起來太有秩序了,不太自然。明天(Day 12),我們要讓光珠們在瓶底自然地堆疊、輕微傾斜,看起來更符合真實世界裡將珠子放進容器中的樣子。


上一篇
【Day 10】列表渲染:v-for 與光珠可視化
下一篇
【Day 12】自然散落效果:用隨機微位移打破過度整齊的堆疊
系列文
微光採集瓶:用 Vue 3 打造每日美好記錄網頁 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言