滚动叙事

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

Scrollytelling 是一种用于打造引人入胜、身临其境的 Web 体验的热门技术。它涉及在用户滚动页面时为页面上的元素添加动画效果,从而有效地讲述故事或引导用户浏览叙事。借助 CSS 滚动驱动动画,您可以直接在 CSS 中创建这些效果,而无需依赖 JavaScript。动画由滚动位置控制,而不是由基于时间的时钟控制,这可确保动画始终与用户的滚动同步。

如何实现

若要打造滚动叙事体验,您需要两组元素:一组用于跟踪滚动位置,另一组用于添加动画效果。

首先,在要跟踪的元素上定义一个命名的 view-timeline。这些将作为动画的驱动因素。

#tracked {
  section:nth-child(1){ view-timeline: --tl-1 block; }
  section:nth-child(2){ view-timeline: --tl-2 block; }
  section:nth-child(3){ view-timeline: --tl-3 block; }
  section:nth-child(4){ view-timeline: --tl-4 block; }
  section:nth-child(5){ view-timeline: --tl-5 block; }
}

接下来,为要添加动画效果的元素应用动画,并使用 animation-timeline 属性将这些元素关联到您刚刚创建的时间轴。

#animated {
  section {
    animation: animate-in auto linear both, animate-out auto linear forwards;
    animation-range: entry 25% cover 50%, exit 50% exit 75%;
  }

  section:nth-child(1){ animation-timeline: --tl-1; }
  section:nth-child(2){ animation-timeline: --tl-2; }
  section:nth-child(3){ animation-timeline: --tl-3; }
  section:nth-child(4){ animation-timeline: --tl-4; }
  section:nth-child(5){ animation-timeline: --tl-5; }
}

为了让 animation-timeline 能够引用具名时间轴,它们需要位于同一作用域内。您可以在共同祖先元素上使用 timeline-scope 属性,使时间轴可供需要它们的所有元素使用。:root 元素通常是实现此目的的理想选择。

html {
  timeline-scope: --tl-1, --tl-2, --tl-3, --tl-4, --tl-5;
}

最后,您可以使用 animation-range 属性指定动画应在时间轴中的哪个确切范围内运行。这样,您就可以精细控制动画的触发时间和进度。

#animated section {
  animation-range: entry 25% cover 50%, exit 50% exit 75%;
}

示例代码

html {
  timeline-scope: --tl-1, --tl-2, --tl-3, --tl-4, --tl-5;
}

#tracked {
  section:nth-child(1){ view-timeline: --tl-1 block; }
  section:nth-child(2){ view-timeline: --tl-2 block; }
  section:nth-child(3){ view-timeline: --tl-3 block; }
  section:nth-child(4){ view-timeline: --tl-4 block; }
  section:nth-child(5){ view-timeline: --tl-5 block; }
}

@keyframes animate-in {
  from { scale: 0.5; opacity: 0; transform: rotateY(-180deg); }
  to { transform: rotateY(0deg); }
}
@keyframes animate-out {
  to { translate: 100% 0; opacity: 0; }
}

#animated {
  section {
    animation: animate-in auto linear both, animate-out auto linear forwards;
    animation-range: entry 25% cover 50%, exit 50% exit 75%;
    backface-visibility: hidden;
  }

  section:nth-child(1){ animation-timeline: --tl-1; }
  section:nth-child(2){ animation-timeline: --tl-2; }
  section:nth-child(3){ animation-timeline: --tl-3; }
  section:nth-child(4){ animation-timeline: --tl-4; }
  section:nth-child(5){ animation-timeline: --tl-5; }
}

/* MANDATORY Copy-Paste Safety: Disable continuous storytelling motion for sensitive users */
@media (prefers-reduced-motion: reduce) {
  #animated section {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
}

最佳做法

使用滚动驱动动画时,请务必遵循一些最佳实践,以确保提供流畅且无障碍的体验:

  • 务必添加功能检测:并非所有浏览器都支持滚动驱动的动画。使用 @supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) 检查是否支持,并为不支持的浏览器提供后备方案。
    • 此处必须包含 (animation-range: 0% 100%) 检查,以滤除仅提供部分支持的浏览器。
    • 请勿将 scroll-timeline-polyfill 软件包用于回退策略,因为它功能不完整,并且存在许多已知问题。
    • 如果动画仅被视为装饰性元素,请选择渐进增强,不要提供后备。
  • 务必尊重用户偏好:有些用户更喜欢在网页上看到较少的动画。使用 prefers-reduced-motion 媒体查询为这些用户停用或减少动画。
  • 务必尝试仅为高性能 CSS 属性添加动画效果:为了获得最流畅的动画效果,请坚持为可由浏览器的合成器线程处理的属性(例如 transform 和 opacity)添加动画效果。为其他属性(例如 width 或 height)添加动画效果可能会导致性能问题。
  • 务必使用正确的声明顺序:使用 animation 简写属性时,请在它之后声明 animation-timeline 和 animation-range,以防止简写属性重置时间轴。

使用 view-timeline 属性创建滚动驱动的动画时:

  • 务必使用 CSS <dashed-ident> 作为名称。
  • 可选:明确指定要跟踪的轴。如果目标不是默认的 block 轴(例如在水平滚动条中),请使用 view-timeline-axis 明确指定要跟踪的轴。
  • 务必确保查找范围有效:如果声明 view-timeline 的元素不是动画元素的平面树祖先,请在共享祖先上使用 timeline-scope 来提升 view-timeline 名称的可见性。

后备策略

对于不支持滚动驱动动画的浏览器,您可以使用后备方案来重现视觉效果。后备方案通常使用滚动监听器(用于 ScrollTimeline 效果)或 IntersectionObserver API(用于 ViewTimeline 效果)构建。

在内置支持滚动驱动动画的浏览器中,请始终使用原生 CSS 实现,因为这些实现的性能更高。

请注意,并非所有效果都可以通过后备方法重现。

具体而言,对于此用例,以下脚本会为不支持滚动驱动动画的浏览器应用回退。它使用 IntersectionObserver 跟踪每个 #tracked section 元素的可见性,并相应地更新对应 #animated section 的 transform 属性。

const animatedSections = document.querySelectorAll('#animated section');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const sectionIndex = Array.from(document.querySelectorAll('#tracked section')).indexOf(entry.target);
    if (sectionIndex !== -1) {
      const animatedSection = animatedSections[sectionIndex];
      const ratio = entry.intersectionRatio;

      // Animate-in
      animatedSection.style.opacity = ratio;
      animatedSection.style.transform = `scale(${0.5 + ratio * 0.5}) rotateY(${-180 + ratio * 180}deg)`;

      // Animate-out
      if (ratio < 0.5) {
        animatedSection.style.translate = `${(0.5 - ratio) * 2 * 100}% 0`;
      } else {
        animatedSection.style.translate = '0 0';
      }
    }
  });
}, { threshold: Array.from({length: 101}, (_, i) => i / 100) });

document.querySelectorAll('#tracked section').forEach(section => {
  observer.observe(section);
});

以及随附的 CSS:

#animated section {
  opacity: 0;
  transform: scale(0.5)  rotateY(-180deg);
  backface-visibility: hidden;
}

/* MANDATORY Copy-Paste Safety: Ensure content remains fully visible and legible for assistive technologies or users with motion sensitivities */
@media (prefers-reduced-motion: reduce) {
  #animated section {
    opacity: 1 !important;
    transform: none !important;
    translate: 0 0 !important;
  }
}

此后备方案可为不支持原生 CSS 功能的浏览器提供更准确的滚动驱动动画,从而确保为所有用户提供更一致的体验。通过为 IntersectionObserver 使用一系列阈值,我们可以更精确地跟踪滚动位置,并创建更流畅的动画。