建立捲動視差效果

網頁功能: 捲動驅動動畫

捲動視差效果是一種視覺技術,當使用者向下捲動網頁時,不同層的內容會以不同速度移動。這會產生深度錯覺,前景元素看起來比背景元素移動得更快,進而帶來引人入勝且身歷其境的瀏覽體驗。如要達到最佳效果,建議使用 CSS 捲動式動畫,將動畫連結至容器的捲動位置。

導入方式

以下說明如何建立基本視差效果:

  1. 建立包裝函式元素:這個元素會將視差效果的所有圖層分組。這不是可捲動的元素,因此應裁剪溢出的內容。此外,請為其提供與視差效果其中一個圖層高度相符的 height。

    <div class="wrapper">
      …
    </div>
    
    .wrapper {
      overflow: clip;
      height: 100vh; /* Height of one of the layers of the parallax */
    }
    
  2. 宣告圖層:在包裝函式中,新增會以不同速度移動的個別圖層。

    <div class="wrapper">
      <div class="layer">LAYER 0</div>
      <div class="layer">LAYER 1</div>
      <div class="layer">LAYER 2</div>
      …
    </div>
    
  3. 新增平移動畫:定義 CSS 動畫,變更圖層的 transform 屬性。如要達到視差效果,通常會使用 translateY 垂直移動圖層。

    @keyframes parallax {
      from {
        transform: translateY(700px);
      }
    }
    
  4. 設定 view-timeline:如要將動畫連結至捲動位置,請在包裝函式元素上建立 view-timeline,然後套用至圖層。

    .wrapper {
      view-timeline: --wrapper;
    }
    
    .layer {
      animation: parallax linear both;
      animation-timeline: --wrapper;
    }
    
  5. 錯開動畫:如要讓圖層以不同速度移動,可以使用兩種主要方法:錯開關鍵影格,或錯開 animation-range。

    這兩種方法都可以使用硬式編碼值,或使用 sibling-index()/sibling-count() 實作。硬式編碼值最簡單,在圖層數量有限時也很有用。如果有多個圖層,sibling-index()/sibling-count() 實作會很實用。

    • 在主要畫面格中交錯:

      您可以使用硬式編碼值,為每個圖層定義自訂屬性,手動控制視差偏移。

      .layer:nth-child(1) { --offset: 100px; }
      .layer:nth-child(2) { --offset: 200px; }
      .layer:nth-child(3) { --offset: 300px; }
      
      @keyframes parallax {
        from {
          transform: translateY(var(--offset));
        }
      }
      

      使用 sibling-index(),讓 sibling-index() 函式傳回子元素在同層級元素中的索引,自動計算交錯效果。

      @keyframes parallax {
        from {
          transform: translateY(calc(100px * sibling-index()));
        }
      }
      
    • 錯開animation-range:

      使用硬式編碼值,您可以明確定義每個圖層上 animation-range 的邊界。

      .layer:nth-child(1) { animation-range: entry 25% exit 50%; }
      .layer:nth-child(2) { animation-range: entry 25% exit 75%; }
      .layer:nth-child(3) { animation-range: entry 25% exit 100%; }
      

      使用 sibling-index() 和 sibling-count(),您可以根據圖層總數 (sibling-count()) 計算範圍。

      .layer {
        animation-range: entry 25% exit calc(100% / sibling-count() * sibling-index());
      }
      

範例程式碼

@keyframes parallax {
  from {
    transform: translateY(calc(100px * sibling-index()));
  }
}

.wrapper {
  view-timeline: --wrapper;
}

.layer {
  animation: parallax linear both;
  animation-timeline: --wrapper;
}

@media (prefers-reduced-motion: reduce) {
  .layer {
    animation: none;
  }
}

或者,您也可以使用 animation-range 屬性達到類似效果:

@keyframes parallax {
  from {
    transform: translateY(700px);
  }
}

.wrapper {
  view-timeline: --wrapper;
}

.layer {
  animation: parallax linear both;
  animation-timeline: --wrapper;
  animation-range: entry 25% exit calc(100% / sibling-count() * sibling-index());
}

@media (prefers-reduced-motion: reduce) {
  .layer {
    animation: none;
  }
}

最佳做法

使用捲動驅動動畫時,請務必遵循幾項最佳做法,確保體驗流暢且無障礙:

  • 務必加入功能偵測:並非所有瀏覽器都支援捲動驅動動畫。使用 @supports ((animation-timeline: view()) and (animation-range: entry)) 檢查支援情形,並為不支援的瀏覽器提供備用方案。
    • 這裡必須加入 (animation-range: entry) 檢查,篩除僅部分支援的瀏覽器。
    • 請勿使用 scroll-timeline-polyfill 套件做為備用策略,因為該套件的功能不完整,且有許多已知問題。
    • 如果動畫僅用於裝飾,請選擇漸進增強,且「不要」提供備用方案。
  • 務必尊重使用者偏好:部分使用者偏好網頁上較少動態效果。使用 prefers-reduced-motion 媒體查詢,為這些使用者停用或減少動畫。
  • 建議只為高效能 CSS 屬性製作動畫:如要讓動畫最流暢,請只為瀏覽器合成器執行緒可處理的屬性製作動畫,例如 transform 和 opacity。為 width 或 height 等其他屬性建立動畫,可能會導致效能問題。
  • 建議使用正確的宣告順序:使用 animation 簡寫屬性時,請在之後宣告 animation-timeline 和 animation-range,以免簡寫重設時間軸。

設定 animation-range 的方式如下:

  • 建議做法:為所有圖層設定相同的起始偏移,例如 entry 25%
  • 建議為所有圖層指定不同的結束偏移,並使用 sibling-count() 和 sibling-index() 分配偏移,例如 exit calc(100% / sibling-count() * sibling-index())。

瀏覽器支援和備用策略

因此,通常需要備用策略。

如果瀏覽器不支援捲動驅動動畫,可以使用備用方案重新建立視覺效果。備用方案通常是使用捲動事件監聽器 (用於 ScrollTimeline 效果) 或 IntersectionObserver API (用於 ViewTimeline 效果) 建構。

如果瀏覽器內建支援捲動式動畫,請一律使用原生 CSS 實作,因為這類實作的效能較佳。

請注意,並非所有效果都能使用備用方法重新建立。

針對這個用途,下列指令碼會為不支援捲動驅動動畫的瀏覽器套用備用方案。這個函式會使用 IntersectionObserver 追蹤 .wrapper 元素的可見度,並根據捲動位置更新圖層的 transform 屬性。

// Fallback for browsers that don't support scroll-driven animations
if (!CSS.supports('(animation-timeline: view()) and (animation-range: entry)')) {
  const wrapper = document.querySelector('.wrapper');
  const layers = document.querySelectorAll('.layer');

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        window.addEventListener('scroll', onScroll);
      } else {
        window.removeEventListener('scroll', onScroll);
      }
    });
  }, { threshold: 0 });

  observer.observe(wrapper);

  function onScroll() {
    const scrollY = window.scrollY;
    const wrapperRect = wrapper.getBoundingClientRect();
    const wrapperTop = wrapperRect.top + scrollY;
    const wrapperHeight = wrapperRect.height;
    const windowHeight = window.innerHeight;

    if (scrollY >= wrapperTop - windowHeight && scrollY <= wrapperTop + wrapperHeight) {
      const scrollPercent = (scrollY - (wrapperTop - windowHeight)) / (wrapperHeight + windowHeight);
      
      layers.forEach((layer, index) => {
        // This matches the effect as defined in the CSS example above.
        // Customize this further if needed.
        const initialTranslateY = 100 * index;
        const translateY = initialTranslateY * (1 - scrollPercent);
        layer.style.transform = `translateY(${translateY}px)`;
      });
    }
  }

  // Trigger onScroll once to set initial positions
  onScroll();
}