构建滚动视差效果

Web 功能: 滚动条驱动的动画

滚动视差效果是一种视觉技术,当用户向下滚动页面时,不同层的内容会以不同的速度移动。这会营造出一种深度错觉,让前景元素看起来比背景元素移动得更快,从而带来引人入胜、身临其境的浏览体验。最好使用 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();
}