前端開發筆記:Scroll-driven Animations

Oliver Xiong
23 min readDec 13, 2023

--

前言

2023 Google I/O 中,CSS推出了眾多令人振奮的新特性和功能,例如:容器查詢 (Container queries)、樣式查詢 (Style queries)、:has() 選擇器、color-mix() 函數、巢狀結構 (Nesting)、三角函數、滾動式動畫 (Scroll-driven animations) 等功能。

其中,最令我感到驚奇的莫過於就是「滾動式動畫」了,這使得以往都需要使用到 JavaScript 監聽滾動的互動,往後只需要純 CSS 就可以實現同樣的互動效果了,雖然目前因兼容性問題還無法實際導入項目當中,但我們還是可以先來了解一下這個新功能。

簡介

  • 傳統 CSS 動畫

在傳統 CSS 中,我們會透過 @keyframes 來建立動畫,並使用 animation-name 屬性將此功能連結至元素,同時設定 animation-duration 來決定動畫的時長,此外,也還可以 animation-timing-functionanimation-delayanimation-iteration-countanimation-* 等屬性來為動畫做更詳細的設定,這些屬性都可以在 animation 簡寫中合併使用。

// 以下動畫隨著時間,放大 X 軸上的元素
@keyframes scale-up {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}

#progress {
animation: scale-up 2.5s linear forwards;
}
  • 滾動式動畫

滾動式動畫是指當用戶滾動頁面時,會觸發特定的動畫效果,例如元素在滾動過程中的移動、縮放、旋轉等效果,然而動畫的執行過程將改由頁面滾動來控制,動畫只會隨著頁面滾動而變化,時間不再起作用

滾動式動畫
Image Reveal Effects

技術實現

在預設情況下,CSS 動畫的時間軸是依照文件時間軸 (document timeline) 執行的,也就是在網頁載入時從 0 開始計算,並隨著時間流逝而開始執行。

在 Chrome 115 版本中,正式支援了 CSS animation-timeline,同時也新增了兩種新的動畫時間軸(滾動進度時間軸&查看進度時間軸),使得以往需要透過 JavaScript 監聽滾動的互動幾乎都可以使用純 CSS 實現了。

animation-timeline 瀏覽器支援
瀏覽器支援

技術實現:滾動進度時間軸 Scroll Progress Timeline

當頁面或容器滾動時,會將滾動範圍中的位置轉換為百分比,並反映在動畫進度上。(滾動起始位置為 0 %、結束位置為 100 %。)

滾動進度時間軸可視化範例
Scroll Timeline Progress Visualizer
  • 建立匿名時間軸

使用 scroll() 建立,並可傳遞 <scroller><axis> 兩個參數。

<scroller> 參數
<scroller> 參數
<axis> 參數
<axis> 參數
// 無參數
animation-timeline: scroll();

// 設定滾動容器
animation-timeline: scroll(nearest); // 預設
animation-timeline: scroll(root);
animation-timeline: scroll(self);

// 設定滾動方向
animation-timeline: scroll(block); // 預設
animation-timeline: scroll(inline);
animation-timeline: scroll(y);
animation-timeline: scroll(x);

// 同時設定滾動容器及方向
animation-timeline: scroll(block nearest); // 預設
animation-timeline: scroll(inline root);
animation-timeline: scroll(x self);
  • 建立命名時間軸

使用 scroll-timeline-name 屬性指定滾動容器,屬性名稱必須以 -- 開頭來命名(如 CSS 變數),並通過 scroll-timeline-axis 設定滾動方向,兩者還可以簡寫成 animation-timeline 屬性。

// 滾動動畫
@keyframes animate-it {
...
}

// 滾動容器
.container {
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block;

// 簡寫
scroll-timeline: --my-scroller block;

.elememt {
animation: animate-it linear;
animation-timeline: --my-scroller;
}
}

技術實現:查看進度時間軸 View Progress Timeline

追蹤一個元素出現在滾動容器內的顯示比例,此時間軸從元素與滾動容器產生交集時開始,並於停止交會時結束。(元素進入容器前為 0 %、離開容器後為 100 %。)

查看進度時間軸可視化範例
View Timeline Progress Visualizer
  • 建立匿名時間軸

使用 view() 建立,並可傳遞 <axis><view-timeline-inset> 兩個參數。

<axis> 參數
<axis> 參數
<inset> 參數
<inset> 參數
// 無參數
animation-timeline: view();

// 設定滾動方向
animation-timeline: view(block); // 預設
animation-timeline: view(inline);
animation-timeline: view(y);
animation-timeline: view(x);

// 設定元素的查看範圍
animation-timeline: view(auto); // 預設
animation-timeline: view(20%);
animation-timeline: view(200px);
animation-timeline: view(20% 40%);
animation-timeline: view(20% 200px);
animation-timeline: view(100px 200px);
animation-timeline: view(auto 200px);

// 同時設定滾動方向及查看範圍
animation-timeline: view(block auto); // 預設
animation-timeline: view(inline 20%);
animation-timeline: view(x 200px auto);
  • 建立命名時間軸

與滾動進度時間軸的命名方式相似,可以使用 view-timeline-name 來命名,並使用 view-timeline-axisview-timeline-inset 來設定滾動方向和查看範圍。

// 滾動動畫
@keyframes animate-it {
...
}

// 元素
.element {
// 創建 View Timeline
view-timeline-name: --reveal;
view-timeline-axis: block;

// 動畫連結 View Timeline
animation: animate-it linear auto both;
animatiom-timeline: --reveal;
}

技術實現:動畫範圍區間

在預設情況下,滾動進度會反映在動畫進度上,滾動多少,動畫的進度也就演示多少。

動畫範圍區間,以 Back to Top 按鈕為例
動畫範圍區間,以 Back to Top 按鈕為例

但有時候,我們並不需要完整的滾動區間,例如上面圖示中的 Back to Top 按鈕,我們只需要滾動一定距離就可以讓按鈕完全出現,想要這樣擷取特定的滾動區間,我們就可以使用到 animation-range 屬性。

animation-range 是個簡寫屬性,並可以傳遞 <animation-range-start><animation-range-end> 兩個參數。

animation-range 參數值
animation-range 參數值
<timeline-range-name> 示意圖
<timeline-range-name> 示意圖

如果還是對 <timeline-range-name> 不太了解的話,可以參考下面連結:

<animation-range-start> / <animation-range-end> 之值有以下幾種組成:

  1. normal(預設):於開始和結束時,分別代表 0% 和 100%。
  2. <length-percentage>:從時間軸開頭測量的長度或百分比。
  3. <timeline-range-name>
    屬於 <animation-range-start> 時,代表該具名時間軸 0%;
    屬於 <animation-range-end> 時,代表該具名時間軸 100%。
  4. <timeline-range-name> + <length-percentage>
    屬於 <animation-range-start> 時,代表該時間軸從該組合值開始;
    屬於 <animation-range-end> 時,代表該時間軸至該組合值結束。

因此,下面的 animation-range 語法都是可行的:

  1. 只指定一個參數:
    - normal<length-percentage>:會被視為 <animation-range-start>,而 <animation-range-end> 預設為 normal
    - <timeline-range-name>:該具名時間軸 0% 到 100%。
    - <timeline-range-name> + <length-percentage>:該具名時間軸 <length-percentage> 到 100%。
  2. 指定兩個參數:第一個參數為 <animation-range-start>、第二個參數為<animation-range-end>
// 指定一個參數
animation-range: normal; // normal normal
animation-range: 20%; // 20% normal
animation-range: 100px; // 100px normal
animation-range: cover; // cover 0% cover 100%
animation-range: contain; // contain 0% contain 100%
animation-range: cover 20%; // cover 20% cover 100%
animation-range: contain 100px; // contain 100px cover 100%

// 指定兩個參數
animation-range: normal 25%;
animation-range: 25% normal;
animation-range: 25% 50%;
animation-range: entry exit; // entry 0% exit 100%
animation-range: cover cover 200px; // cover 0% cover 200px
animation-range: entry 10% exit; // entry 10% exit 100%
animation-range: 10% exit 90%;
animation-range: entry 10% 90%;

應用案例:

  • Progress Indicator
Progress Indicator
// 動畫
@keyframes progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}

// 滾動容器
html {
scroll-timeline: --progress block; // 命名及指定滾動容器,並設定滾動方向

// 動畫元素: 進度指示
.progress {
transform-origin: 0 50%;
animation: progress auto linear;
animation-timeline: --progress; // 指定動畫時間軸
}
}
  • Text Reveal
<p class="text">
<span>
<!-- 文字內容 -->
</span>
</p>
:root {
// 依照字元數設定變數
--chars: 445;
}

// 動畫
@keyframes text-reveal {
0% {
background-size: 0ch; // 1ch 是一個數字 0 的寬度
}
100% {
background-size: calc(var(--chars) * 1ch);
}
}

body {
// 設定滾動空間
height: 500dvh;

.text {
// 固定文字位置
position: fixed;
top: 50%;
left: 50%;
translate: -50% -50%;
/* 若設定 text-align: justify 會影響動畫呈現,若要使用需再自行調整 */

span {
// 設定背景顏色 (初始文字顏色) 和圖片 (reveal 文字顏色)
background: #eee linear-gradient(to right, #37ecba 0%, #72afd3 100%) 0 / 0
no-repeat;
// 設定文字遮罩
background-clip: text;
color: transparent;
// 設定動畫、匿名滾動進度時間軸及滾動容器
animation: text-reveal steps(var(--chars)) forwards;
animation-timeline: scroll(root);
}
}
}
  • Text Inversion
// 動畫
@keyframes invert {
from {
scale: 0 1;
}
}

html {
// 設定滾動空間
height: 200%;
background-color: #000;
color: #fff;

&::before {
content: "";
position: fixed;
inset: 0;
background-color: #fff;
transform-origin: 100%;
// 設定動畫及匿名滾動進度時間軸
animation: invert linear;
animation-timeline: scroll(); // scroll(nearest block)
}

// invert 元素
.element {
// 固定位置
position: fixed;
top: 50%;
left: 50%;
translate: -50% -50%;
// 負片效果
mix-blend-mode: exclusion;
}
}
  • Sticky Header
Sticky Header
// 動畫
@keyframes sticky-header {
from {
height: 100vh;
font-size: calc(10vw + 1rem);
}
to {
height: 10vh;
font-size: 2rem;
}
}

// 動畫元素: Header
header {
animation: sticky-header linear both;
animation-timeline: scroll(); // 建立匿名時間軸 scroll(nearest block)
animation-range: 0vh 100vh; // 設定動畫開始與結束範圍
}
  • Back to Top
Back to Top
// 動畫
@keyframes reveal {
from {
transform: translateY(200%);
}
to {
transform: translateY(0%);
}
}

// 動畫元素: Back to Top 按鈕
.back {
animation: reveal linear;
animation-timeline: scroll();
animation-range: 0vh 10vh;
}
  • Horizontal Scroll Section
Horizontal Scroll Section
Horizontal Scroll Section 示意圖
Horizontal Scroll Section 示意圖
<section class="section-pin">
<div class="pin-wrap-sticky">
<div class="pin-wrap">
<!-- 橫向滾動內容 -->
</div>
</div>
</section>
// 動畫
@keyframes move {
to {
// 水平移動使「滾動內容右側」與「視窗」對齊
transform: translateX(calc(-100% + 100vw));
left: 0;
}
}

.section-pin {
// 伸展區塊高度,為橫向滾動動畫創造空間
height: 500vh;
// 使子元素的 position: sticky 能正常運作
// 父元素設定 over: hidden 會讓子元素的 position: stikcy 失效
overflow: visible;
// 建立並命名察看進度時間軸
view-timeline-name: --section-pin-tl;
// 設定滾動方向
view-timeline-axis: block;

.pin-wrap-sticky {
// 使元素固定在滾動區塊的頂端
position: sticky;
top: 0;
width: 100vw;
height: 100vh;
overflow-x: hidden;

.pin-wrap {
width: 250vmax;
height: 100vh;
// 提示瀏覽器該元素會有 CSS 改變
will-change: transform;
animation: linear move forwards;
// 將動畫與 view-timeline 命名時間軸串聯
animation-timeline: --section-pin-tl;
// 設定動畫開始與結束範圍
animation-range: contain 0% contain 100%;
}
}
}
  • Stacked Cards
Stacked Card
<section class="stacked">
<ui class="cards">
<li class="card" id="card1">
<div class="content">
<!-- 卡片內容 -->
</div>
</li>
<li class="card" id="card2">
<div class="content">
<!-- 卡片內容 -->
</div>
</li>
<li class="card" id="card3">
<div class="content">
<!-- 卡片內容 -->
</div>
</li>
<li class="card" id="card4">
<div class="content">
<!-- 卡片內容 -->
</div>
</li>
</ui>
</section>
// 設定 CSS 變數
:root {
--card-height: 40vw;
--card-margin: 4vw;
--card-top-offset: 1rem;
}

// 設定 index => 編號 - 1
#card1 {
--index: 0;
}

#card2 {
--index: 1;
}

#card3 {
--index: 2;
}

#card4 {
--index: 3;
}

.stacked {
.cards {
// 設定卡片數量變數
--cards: 4;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: repeat(var(--cards), var(--card-height));
gap: var(--card-margin);
margin-bottom: var(--card-margin);
padding-bottom: calc(var(--card-s) * var(--card-top-offset));
list-style: none;
// 設定命名查看進度時間軸
view-timeline-name: --stacked-cards;

.card {
// 設定反向 index
--r-index: calc(var(--cards) - var(--index) - 1);
// 使卡片固定在距離頂端 2.5vh 處
position: sticky;
top: 2.5vh;
// 每張卡片設定不同 padding-top => 堆疊效果
// var(--index) + 1 使 #card1 的 padding-top 不為 0
padding-top: calc((var(--index) + 1) * var(--card-top-offset));

.content {
// 設定動畫開始範圍變數
--start: calc(var(--index) / var(--cards) * 100%);
// 設定動畫結束範圍變數
// var(--index) + 1 使動畫結束範圍 > 開始範圍,且 #card1 的動畫結束範圍不為 0%
--end: calc((var(--index) + 1) / var(--cards) * 100%);
// 設定 transform 起始點 => x 軸 50% 、 y 軸 0% 處
transform-origin: 50% 0%;
// 提示瀏覽器該元素會有 CSS 改變
will-change: transform;
// 設定動畫、時間軸及範圍
animation: linear scale forwards;
animation-timeline: --stacked-cards;
animation-range: exit-crossing var(--start) exit-crossing var(--end);
}
}
}
}

@keyframes scale {
to {
// 設定卡片縮放尺寸 => 越前面卡片越小、越後面卡片越大
transform: scale(calc(1 - calc(0.1 * var(--r-index))));
}
}

優勢及挑戰

  • 優勢

滾動式動畫在眾多現代網頁中已變得相當普遍,以往實現這樣的互動體驗通常需要使用額外的套件(如 GSAP),或者透過 JavaScript 監聽 scroll 事件並獲取 scrollTop 進行計算。然而,頻繁獲取 scrollTop 可能導致頁面卡頓和動畫不流暢的問題。

自從 CSS 推出 animation-timeline 後,讓以往需要複雜處理的滾動式動畫大多能夠使用純 CSS 實現,這種方法更加簡單、高效,且無需額外學習和使用其他套件。animation-timeline 為開發者提供了一種更方便、更優雅的方式,使得網頁設計和互動效果的實現變得更加容易。這不僅減少了開發的複雜性,還提高了動畫效果的性能,為用戶提供更加順暢的瀏覽體驗。

  • 挑戰

新功能推出後,我們首先面臨的挑戰是瀏覽器的支援度。根據 Can I use 的查詢,我們發現一些主流瀏覽器,例如 Firefox(需手動開啟)和 Safari,目前尚未完全支援 animation-timeline

儘管面臨這樣的情況,我們仍然能夠利用 CSS 的 @supports 功能來處理這個問題。透過這種方式,我們可以針對支援和不支援的瀏覽器分別設定不同的 CSS,確保各種瀏覽器都能夠提供良好的使用體驗。

animation-timeline 瀏覽器支援
animation-timeline 瀏覽器支援

結論

CSS animation-timeline 的推出為開發者提供了簡單而高效的滾動式動畫實現方式,同時改進了性能、提升了用戶體驗。儘管面臨瀏覽器支援度的挑戰,仍可透過 CSS @supports 功能,有效處理跨瀏覽器的兼容性問題。

隨著技術和瀏覽器的進一步發展,CSS animation-timeline 的支援度也將逐步提升,為開發者提供更便捷的開發體驗,同時為用戶呈現更流暢的瀏覽效果。總體而言,此功能將持續成為網頁互動設計的重要工具,為未來的網頁開發帶來更多可能性。

--

--