昨天我們成功利用 v-for,把採集到的資料轉化為一顆顆彩色光珠呈現在瓶子裡。但目前點擊按鈕,光珠都是瞬間出現在瓶中,看起來少了一點「被放進瓶子」的互動感。
今天我們要用 CSS @keyframes(關鍵影格),讓新加入的光珠有重量感,使其從瓶子上方自然落入瓶底。
在動手寫 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 屬性,設定了三件事:
dropIn:要播放哪一段 @keyframes
0.6s:動畫總長 0.6 秒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 新增出來的那個新元素,才會經歷「第一次被放入」,因此只有它會播放下墜效果。
目前瀏覽器畫面如下圖所示:
原因:.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;
}
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;
}

存檔後回到瀏覽器,測試看看:
@keyframes 裡的數值(例如把 60px 改成 100px),看看下墜的高度、彈跳的幅度會怎麼變化,感受一下這些數字跟畫面效果之間的關聯。@keyframes:學會定義一段隨時間變化的動畫過程,用百分比標記關鍵時間點的樣式。cubic-bezier):理解動畫的「節奏曲線」怎麼影響播放的手感,搭配手動設計的關鍵影格,做出彈跳感。<TransitionGroup> 這個更強大的工具,並理解為什麼現在還不需要用到它。光珠現在會帶著重量感掉進瓶子裡了!不過如果你多放幾顆光珠,可能會發現每一排都是排成一直線,看起來太有秩序了,不太自然。明天(Day 12),我們要讓光珠們在瓶底自然地堆疊、輕微傾斜,看起來更符合真實世界裡將珠子放進容器中的樣子。