正在构建 Chrometober!

这本滚动式图书是如何诞生的,以及它如何在 Chrometober 期间分享有趣又恐怖的提示和技巧。

Designcember 之后,我们想在今年为您打造 Chrometober,希望它能够展示并分享来自社区和 Chrome 团队的 Web 内容。去年的 Designcember 大会展示了如何使用容器查询,而今年我们将展示 CSS 滚动关联动画 API。

如需查看滚动图书体验,请访问 web.dev/chrometober-2022

概览

该项目的目标是提供一种强调滚动关联的动画 API 的奇妙体验。不过,除了充满奇思妙想之外,该体验还需要响应迅速且易于使用。该项目还非常适合试用正在积极开发的 API polyfill,以及组合使用不同的技术和工具。所有这些都采用了万圣节主题!

我们的团队结构如下所示:

起草滚动式讲述体验

我们于 2022 年 5 月举办了首次团队外出活动,并在那里开始构思 Chrometober 的相关想法。通过一系列涂鸦,我们思考了用户如何在某种形式的故事板中滚动浏览。受视频游戏的启发,我们考虑了在墓地和鬼屋等场景中滚动浏览的体验。

一本笔记本放在桌子上,上面有与项目相关的各种涂鸦和乱写。

能够自由发挥创意,将我的第一个 Google 项目朝着意想不到的方向发展,这让我感到非常兴奋。这是用户浏览内容的早期原型。

当用户侧向滚动时,方块会旋转并缩小。但我担心如何才能为各种尺寸的设备用户打造良好的体验,因此决定放弃这个想法。而是倾向于使用我之前设计过的元素。2020 年,我很幸运地有机会使用 GreenSock 的 ScrollTrigger 构建发布演示版。

我制作的其中一个演示是 3D-CSS 图书,页面会在您滚动时翻页,这感觉更符合我们对 Chrometober 的要求。滚动关联的动画 API 是该功能的绝佳替代方案。它还可以与 scroll-snap 搭配使用,您很快就会看到!

负责此项目的插画家 Tyler Reed 非常擅长根据我们的想法调整设计。Tyler 出色地完成了任务,将我们提出的所有创意想法都变成了现实。一起集思广益真是太有趣了。我们希望实现这一点的一个重要方法是将功能拆分为独立的块。这样,我们就可以将它们组合成场景,然后挑选出我们想要呈现的内容。

一张合成场景图片,其中包含蛇、伸出手臂的棺材、手持魔杖在坩锅旁边的狐狸、长着幽灵面孔的树以及手持南瓜灯的石像。

其主要思路是,用户在浏览图书的过程中可以接触到大量内容。他们还可以与一些奇思妙想的元素互动,包括我们在体验中内置的彩蛋;例如,鬼屋中的肖像,其眼睛会跟随您的指针移动,或者由媒体查询触发的细微动画。这些创意和功能会在滚动时呈现动画效果。最初的想法是,在用户滚动时,一只僵尸兔子会沿 x 轴上升和平移。

熟悉该 API

我们需要一本书,然后才能开始玩各个功能和复活节彩蛋。因此,我们决定借此机会测试一下新推出的 CSS 滚动链接动画 API 的功能集。目前,任何浏览器都不支持滚动链接动画 API。不过,在开发该 API 时,互动团队的工程师一直在研究 polyfill。这样一来,您就可以在 API 开发过程中测试其形态。这意味着,我们现在就可以使用此 API,而像这样的有趣项目通常是试用实验性功能并提供反馈的好地方。如需了解我们总结的经验和所能提供的反馈,请参阅这篇文章后半部分

概括来讲,您可以使用此 API 将动画与滚动相关联。请务必注意,您无法触发滚动动画,这项功能可能会在稍后推出。滚动关联的动画也分为两大类:

  1. 会对滚动位置做出反应的广告。
  2. 这些事件会响应元素在滚动容器中的位置。

如需创建后者,我们使用通过 animation-timeline 属性应用的 ViewTimeline

下面的示例展示了在 CSS 中使用 ViewTimeline 的效果:

.element-moving-in-viewport {
  view-timeline-name: foo;
  view-timeline-axis: block;
}

.element-scroll-linked {
  animation: rotate both linear;
  animation-timeline: foo;
  animation-delay: enter 0%;
  animation-end-delay: cover 50%;
}

@keyframes rotate {
 to {
   rotate: 360deg;
 }
}

我们使用 view-timeline-name 创建 ViewTimeline,并为其定义轴。在此示例中,block 是指逻辑 block动画会通过 animation-timeline 属性与滚动相关联。animation-delayanimation-end-delay(在编写时)是我们定义阶段的方式。

这些阶段定义了相对于元素在滚动容器中的位置,动画应链接到的时间点。在本示例中,我们表示在元素进入(enter 0%)滚动容器时开始动画。并在覆盖滚动容器的 50% (cover 50%) 时结束。

以下是我们的实际演示:

您还可以将动画链接到在视口中移动的元素。为此,您可以将 animation-timeline 设置为元素的 view-timeline。这对于列表动画等场景非常有用。此行为类似于使用 IntersectionObserver 在进入时为元素添加动画效果的方式。

element-moving-in-viewport {
  view-timeline-name: foo;
  view-timeline-axis: block;
  animation: scale both linear;
  animation-delay: enter 0%;
  animation-end-delay: cover 50%;
  animation-timeline: foo;
}

@keyframes scale {
  0% {
    scale: 0;
  }
}

这样一来,“Mover”在进入视口时会放大,从而触发“Spinner”的旋转。

我通过实验发现,该 API 能够很好地与 scroll-snap 配合使用。将滚动捕获与 ViewTimeline 结合使用非常适合在图书中捕获翻页。

对机制进行原型设计

经过一番实验,我成功制作了一个可用的图书原型。您可以横向滚动来翻阅图书的页面。

在演示中,您可以看到不同触发器以虚线边框突出显示。

标记大致如下所示:

<body>
  <div class="book-placeholder">
    <ul class="book" style="--count: 7;">
      <li
        class="page page--cover page--cover-front"
        data-scroll-target="1"
        style="--index: 0;"
      >
        <div class="page__paper">
          <div class="page__side page__side--front"></div>
          <div class="page__side page__side--back"></div>
        </div>
      </li>
      <!-- Markup for other pages here -->
    </ul>
  </div>
  <div>
    <p>intro spacer</p>
  </div>
  <div data-scroll-intro>
    <p>scale trigger</p>
  </div>
  <div data-scroll-trigger="1">
    <p>page trigger</p>
  </div>
  <!-- Markup for other triggers here -->
</body>

滚动时,图书的页面会翻动,但会快速打开或关闭。这取决于触发器的滚动并紧贴对齐方式。

html {
  scroll-snap-type: x mandatory;
}

body {
  grid-template-columns: repeat(var(--trigger-count), auto);
  overflow-y: hidden;
  overflow-x: scroll;
  display: grid;
}

body > [data-scroll-trigger] {
  height: 100vh;
  width: clamp(10rem, 10vw, 300px);
}

body > [data-scroll-trigger] {
  scroll-snap-align: end;
}

这一次,我们不在 CSS 中连接 ViewTimeline,而是在 JavaScript 中使用 Web Animations API。这样做的另一个好处是,我们可以循环处理一组元素并生成所需的 ViewTimeline,而无需手动创建每个 ViewTimeline

const triggers = document.querySelectorAll("[data-scroll-trigger]")

const commonProps = {
  delay: { phase: "enter", percent: CSS.percent(0) },
  endDelay: { phase: "enter", percent: CSS.percent(100) },
  fill: "both"
}

const setupPage = (trigger, index) => {
  const target = document.querySelector(
    `[data-scroll-target="${trigger.getAttribute("data-scroll-trigger")}"]`
  );

  const viewTimeline = new ViewTimeline({
    subject: trigger,
    axis: 'inline',
  });

  target.animate(
    [
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`
      },
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`,
        offset: 0.75
      },
      {
        transform: `translateZ(${(triggers.length - index) * -1}px)`
      }
    ],
    {
      timeline: viewTimeline,
      commonProps,
    }
  );
  target.querySelector(".page__paper").animate(
    [
      {
        transform: "rotateY(0deg)"
      },
      {
        transform: "rotateY(-180deg)"
      }
    ],
    {
      timeline: viewTimeline,
      commonProps,
    }
  );
};

const triggers = document.querySelectorAll('[data-scroll-trigger]')
triggers.forEach(setupPage);

对于每个触发器,我们都会生成一个 ViewTimeline。然后,我们使用该 ViewTimeline 为触发器的关联页面添加动画效果。这会将网页的动画与滚动相关联。在动画中,我们将沿着 y 轴旋转页面的元素,以便翻转页面。我们还会沿 Z 轴平移页面本身,使其像书本一样翻页。

综合应用

确定了图书的机制后,我就可以专注于将 Tyler 的插图变为现实。

Astro

该团队在 2021 年采用了 Astro for Designcember,而我非常期待在 Chrometober 中再次使用它。开发者能够将组件分解为多个组件,这种体验非常适合此项目。

图书本身就是一个组件。它也是一系列页面组件。每页都有两面,并且带有背景。页面边栏的子项是可轻松添加、移除和定位的组件。

制作照片书

使这些组成要素易于管理对我来说非常重要。我还希望让团队其他成员能够轻松做出贡献。

页面在高层次由配置数组定义。数组中的每个页面对象都定义了页面的内容、背景和其他元数据。

const pages = [
  {
    front: {
      marked: true,
      content: PageTwo,
      backdrop: spreadOne,
      darkBackdrop: spreadOneDark
    },
    back: {
      content: PageThree,
      backdrop: spreadTwo,
      darkBackdrop: spreadTwoDark
    },
    aria: `page 1`
  },
  /* Obfuscated page objects */
]

这些对象会被传递给 Book 组件。

<Book pages={pages} />

Book 组件用于应用滚动机制并创建图书页面。使用的是原型中的相同机制;但我们会共享全局创建的多个 ViewTimeline 实例。

window.CHROMETOBER_TIMELINES.push(viewTimeline);

这样,我们就可以共享时间轴以供在其他位置使用,而无需重新创建。稍后我们会详细介绍这部分内容。

页面组成

每个页面都是列表中的列表项:

<ul class="book">
  {
    pages.map((page, index) => {
      const FrontSlot = page.front.content
      const BackSlot = page.back.content
      return (
        <Page
          index={index}
          cover={page.cover}
          aria={page.aria}
          backdrop={
            {
              front: {
                light: page.front.backdrop,
                dark: page.front.darkBackdrop
              },
              back: {
                light: page.back.backdrop,
                dark: page.back.darkBackdrop
              }
            }
          }>
          {page.front.content && <FrontSlot slot="front" />}    
          {page.back.content && <BackSlot slot="back" />}    
        </Page>
      )
    })
  }
</ul>

然后,定义的配置会传递给每个 Page 实例。这些页面使用 Astro 的槽功能将内容插入到每个页面中。

<li
  class={className}
  data-scroll-target={target}
  style={`--index:${index};`}
  aria-label={aria}
>
  <div class="page__paper">
    <div
      class="page__side page__side--front"
      aria-label={`Right page of ${index}`}
    >
      <picture>
        <source
          srcset={darkFront}
          media="(prefers-color-scheme: dark)"
          height="214"
          width="150"
        >
        <img
          src={lightFront}
          class="page__background page__background--right"
          alt=""
          aria-hidden="true"
          height="214"
          width="150"
        >
      </picture>
      <div class="page__content">
        <slot name="front" />
      </div>
    </div>
    <!-- Markup for back page -->
  </div>
</li>

此代码主要用于设置结构。贡献者在处理图书内容时,大部分情况下无需接触此代码。

背景幕广告素材

创意向图书转变,使得拆分部分变得更加轻松,而且图书的每个分布都是从原始设计中提取的一个场景。

这本书中的书页展开插图,描绘了墓地里一棵苹果树。墓地有多个墓碑,天空中有一只蝙蝠在巨大的月亮前飞舞。

由于我们已确定图书的宽高比,因此每页的背景都可以包含图片元素。将该元素的宽度设置为 200%,并根据页面边使用 object-position 即可解决问题。

.page__background {
  height: 100%;
  width: 200%;
  object-fit: cover;
  object-position: 0 0;
  position: absolute;
  top: 0;
  left: 0;
}

.page__background--right {
  object-position: 100% 0;
}

网页内容

我们来看看如何构建其中一个页面。第 3 页显示了一只在树上弹出的猫头鹰。

按照配置中所定义的,系统将使用 PageThree 组件填充该组件。它是一种 Astro 组件 (PageThree.astro)。这些组件看起来像 HTML 文件,但顶部有一个类似于前言的代码方括号。这样,我们就可以执行导入其他组件等操作。第三页的组件如下所示:

---
import TreeOwl from '../TreeOwl/TreeOwl.astro'
import { contentBlocks } from '../../assets/content-blocks.json'
import ContentBlock from '../ContentBlock/ContentBlock.astro'
---
<TreeOwl/>
<ContentBlock {...contentBlocks[3]} id="four" />

<style is:global>
  .content-block--four {
    left: 30%;
    bottom: 10%;
  }
</style>

再次强调一下,网页在本质上是原子操作。它们由一系列地图项构建而成。第 3 页包含内容块和互动猫头鹰,因此分别为它们创建了组件。

内容块是指指向图书中内容的链接。这些事件也由配置对象驱动。

{
 "contentBlocks": [
    {
      "id": "one",
      "title": "New in Chrome",
      "blurb": "Lift your spirits with a round up of all the tools and features in Chrome.",
      "link": "https://www.youtube.com/watch?v=qwdN1fJA_d8&list=PLNYkxOF6rcIDfz8XEA3loxY32tYh7CI3m"
    },
    …otherBlocks
  ]
}

在需要内容屏蔽功能时,系统会导入此配置。然后,相关的块配置会传递给 ContentBlock 组件。

<ContentBlock {...contentBlocks[3]} id="four" />

这里还有一个示例,展示了如何使用页面组件作为放置内容的位置。此处用于放置内容块。

<style is:global>
  .content-block--four {
    left: 30%;
    bottom: 10%;
  }
</style>

不过,内容块的一般样式与组件代码共存。

.content-block {
  background: hsl(0deg 0% 0% / 70%);
  color: var(--gray-0);
  border-radius:  min(3vh, var(--size-4));
  padding: clamp(0.75rem, 2vw, 1.25rem);
  display: grid;
  gap: var(--size-2);
  position: absolute;
  cursor: pointer;
  width: 50%;
}

我们的猫头鹰是一项互动功能,是本项目中众多功能之一。下面是一个不错的小示例,展示了我们如何使用创建的共享 ViewTimeline。

概括来讲,我们的 owl 组件会导入一些 SVG,并使用 Astro 的 Fragment 将其内嵌。

---
import { default as Owl } from '../Features/Owl.svg?raw'
---
<Fragment set:html={Owl} />

用于定位猫头鹰的样式与组件代码共存。

.owl {
  width: 34%;
  left: 10%;
  bottom: 34%;
}

还有一个额外的样式,用于定义猫头鹰的 transform 行为。

.owl__owl {
  transform-origin: 50% 100%;
  transform-box: fill-box;
}

使用 transform-box 会影响 transform-origin。它使其相对于 SVG 中的对象边界框。猫头鹰从底部中心放大,因此使用了 transform-origin: 50% 100%

有趣的一点是,我们将猫头鹰与生成的某个 ViewTimeline 相关联:

const setUpOwl = () => {
   const owl = document.querySelector('.owl__owl');

   owl.animate([
     {
       translate: '0% 110%',
     },
     {
       translate: '0% 10%',
     },
   ], {
     timeline: CHROMETOBER_TIMELINES[1],
     delay: { phase: "enter", percent: CSS.percent(80) },
     endDelay: { phase: "enter", percent: CSS.percent(90) },
     fill: 'both' 
   });
 }

 if (window.matchMedia('(prefers-reduced-motion: no-preference)').matches)
   setUpOwl()

在此代码块中,我们将执行以下两项操作:

  1. 检查用户的动作偏好设置。
  2. 如果用户没有偏好设置,请将猫头鹰的动画与滚动相关联。

对于第二部分,猫头鹰会使用 Web Animations API 在 y 轴上进行动画处理。使用单个转换属性 translate,并将其关联到一个 ViewTimeline。它通过 timeline 属性与 CHROMETOBER_TIMELINES[1] 相关联。这是针对翻页次数生成的 ViewTimeline。这会使用 enter 阶段将猫头鹰的动画与翻页相关联。它定义了当页面翻转 80% 时,开始移动猫头鹰。到达 90% 时,猫头鹰应完成翻译。

图书功能

现在,您已经了解了构建网页的方法以及项目架构的运作方式。您可以了解到该功能如何让贡献者轻松开始处理自己选择的网页或专题。图书中的各种功能的动画都与图书的翻页相关联;例如,在翻页时飞进飞出的蝙蝠。

它还包含由 CSS 动画驱动的元素。

将内容块添加到图书中后,我们就可以充分发挥创意,使用其他功能了。这让我们有机会生成一些不同的互动,并尝试不同的实现方式。

确保应用快速响应

自适应视口单位用于调整图书及其功能的大小。不过,让字体保持响应能力是一项有趣的挑战。容器查询单元非常适合此用途。不过,它们尚未在所有国家/地区受支持。图书的大小已设置,因此我们不需要容器查询。您可以使用 CSS calc() 生成内嵌容器查询单元,并将其用于调整字体大小。


.book-placeholder {
  --size: clamp(12rem, 72vw, 80vmin);
  --aspect-ratio: 360 / 504;
  --cqi: calc(0.01 * (var(--size) * (var(--aspect-ratio))));
}

.content-block h2 {
  color: var(--gray-0);
  font-size: clamp(0.6rem, var(--cqi) * 4, 1.5rem);
}

.content-block :is(p, a) {
  font-size: clamp(0.6rem, var(--cqi) * 3, 1.5rem);
}

夜晚的南瓜

细心的读者可能已经注意到,在前面讨论页面背景时,我们使用了 <source> 元素。Una 希望实现根据配色方案偏好设置进行互动的功能。因此,背景图片支持浅色和深色模式,并提供不同的变体。由于您可以将媒体查询与 <picture> 元素搭配使用,因此这是一种提供两种背景样式的绝佳方式。<source> 元素会查询配色方案偏好设置,并显示相应的背景。

<picture>
  <source srcset={darkFront} media="(prefers-color-scheme: dark)" height="214" width="150">
  <img src={lightFront} class="page__background page__background--right" alt="" aria-hidden="true" height="214" width="150">
</picture>

您可以根据该配色方案偏好设置进行其他更改。第二页上的南瓜会根据用户的配色偏好设置做出响应。所用的 SVG 包含代表火焰的圆圈,这些圆圈会在深色模式下放大并呈现动画效果。

.pumpkin__flame,
 .pumpkin__flame circle {
   transform-box: fill-box;
   transform-origin: 50% 100%;
 }

 .pumpkin__flame {
   scale: 0.8;
 }

 .pumpkin__flame circle {
   transition: scale 0.2s;
   scale: 0;
 }

@media(prefers-color-scheme: dark) {
   .pumpkin__flame {
     animation: pumpkin-flicker 3s calc(var(--index, 0) * -1s) infinite linear;
   }

   .pumpkin__flame circle {
     scale: 1;
   }

   @keyframes pumpkin-flicker {
     50% {
       scale: 1;
     }
   }
 }

这张肖像照会看着您吗?

如果您查看第 10 页,可能会发现一些问题。您正在被监视!当您在页面中移动时,肖像的眼睛会跟随您的指针。这里的技巧是将指针位置映射到 translate 值,并将其传递给 CSS。

const mapRange = (inputLower, inputUpper, outputLower, outputUpper, value) => {
   const INPUT_RANGE = inputUpper - inputLower
   const OUTPUT_RANGE = outputUpper - outputLower
   return outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0)
 }

此代码接受输入和输出范围,并映射给定值。例如,此用法将产生值 625。

mapRange(0, 100, 250, 1000, 50) // 625

对于纵向照片,输入值是每只眼睛的中心点,加上或减去一些像素距离。输出范围是眼睛在像素中的平移量。然后,系统会将 x 轴或 y 轴上的指针位置作为值传递。为了在移动眼睛时获得眼睛的中心点,系统会复制眼睛。原始图片不会移动,是透明的,并且用于参考。

然后,将其整合在一起,并更新眼睛的 CSS 自定义属性值,以便眼睛可以移动。将函数绑定到 windowpointermove 事件。当该函数被触发时,系统将使用每只眼睛的边界来计算中心点。然后,将指针位置映射到在眼睛上设置为自定义属性值的值。

const RANGE = 15
const LIMIT = 80
const interact = ({ x, y }) => {
   // map a range against the eyes and pass in via custom properties
   const LEFT_EYE_BOUNDS = LEFT_EYE.getBoundingClientRect()
   const RIGHT_EYE_BOUNDS = RIGHT_EYE.getBoundingClientRect()

   const CENTERS = {
     lx: LEFT_EYE_BOUNDS.left + LEFT_EYE_BOUNDS.width * 0.5,
     rx: RIGHT_EYE_BOUNDS.left + RIGHT_EYE_BOUNDS.width * 0.5,
     ly: LEFT_EYE_BOUNDS.top + LEFT_EYE_BOUNDS.height * 0.5,
     ry: RIGHT_EYE_BOUNDS.top + RIGHT_EYE_BOUNDS.height * 0.5,
   }

   Object.entries(CENTERS)
     .forEach(([key, value]) => {
       const result = mapRange(value - LIMIT, value + LIMIT, -RANGE, RANGE)(key.indexOf('x') !== -1 ? x : y)
       EYES.style.setProperty(`--${key}`, result)
     })
 }

将值传递给 CSS 后,样式便可以对这些值执行所需的操作。这里最棒的地方在于,您可以使用 CSS clamp() 让每只眼睛的行为有所不同,这样您无需再次触碰 JavaScript 即可让每只眼睛的行为有所不同。

.portrait__eye--mover {
   transition: translate 0.2s;
 }

 .portrait__eye--mover.portrait__eye--left {
   translate:
     clamp(-10px, var(--lx, 0) * 1px, 4px)
     clamp(-4px, var(--ly, 0) * 0.5px, 10px);
 }

 .portrait__eye--mover.portrait__eye--right {
   translate:
     clamp(-4px, var(--rx, 0) * 1px, 10px)
     clamp(-4px, var(--ry, 0) * 0.5px, 10px);
 }

施放法术

如果您查看第六页,会不会被深深吸引?本页介绍了我们神奇狐狸的设计。如果您移动指针,可能会看到自定义光标轨迹效果。此方法使用画布动画。<canvas> 元素位于网页内容的其余部分上方,并带有 pointer-events: none。这意味着用户仍然可以点击下方的内容块。

.wand-canvas {
  height: 100%;
  width: 200%;
  pointer-events: none;
  right: 0;
  position: fixed;
}

与纵向模式监听 window 上的 pointermove 事件类似,<canvas> 元素也是如此。不过,每次事件触发时,我们都会创建一个对象,以便在 <canvas> 元素上添加动画效果。这些对象表示光标轨迹中使用的形状。它们具有坐标和随机色相。

我们再次使用了之前的 mapRange 函数,因为我们可以使用它将指针增量映射到 sizerate。这些对象存储在数组中,当对象被绘制到 <canvas> 元素时,该数组会循环。每个对象的属性会告知 <canvas> 元素应在何处绘制内容。

const blocks = []
  const createBlock = ({ x, y, movementX, movementY }) => {
    const LOWER_SIZE = CANVAS.height * 0.05
    const UPPER_SIZE = CANVAS.height * 0.25
    const size = mapRange(0, 100, LOWER_SIZE, UPPER_SIZE, Math.max(Math.abs(movementX), Math.abs(movementY)))
    const rate = mapRange(LOWER_SIZE, UPPER_SIZE, 1, 5, size)
    const { left, top, width, height } = CANVAS.getBoundingClientRect()
    
    const block = {
      hue: Math.random() * 359,
      x: x - left,
      y: y - top,
      size,
      rate,
    }
    
    blocks.push(block)
  }
window.addEventListener('pointermove', createBlock)

如需绘制到画布,请使用 requestAnimationFrame 创建一个循环。光标轨迹应仅在网页可见时渲染。我们有一个 IntersectionObserver,用于更新和确定当前正在查看的页面。如果页面在视野内,则对象会在画布上呈现为圆形。

然后,我们循环遍历 blocks 数组,并绘制路线的每个部分。每个帧都会缩小对象的大小,并按 rate 更改对象的位置。这样就会产生下落和缩放效果。如果对象完全缩小,则会从 blocks 数组中移除该对象。

let wandFrame
const drawBlocks = () => {
   ctx.clearRect(0, 0, CANVAS.width, CANVAS.height)
  
   if (PAGE_SIX.className.indexOf('in-view') === -1 && wandFrame) {
     blocks.length = 0
     cancelAnimationFrame(wandFrame)
     document.body.removeEventListener('pointermove', createBlock)
     document.removeEventListener('resize', init)
   }
  
   for (let b = 0; b < blocks.length; b++) {
     const block = blocks[b]
     ctx.strokeStyle = ctx.fillStyle = `hsla(${block.hue}, 80%, 80%, 0.5)`
     ctx.beginPath()
     ctx.arc(block.x, block.y, block.size * 0.5, 0, 2 * Math.PI)
     ctx.stroke()
     ctx.fill()

     block.size -= block.rate
     block.y += block.rate

     if (block.size <= 0) {
       blocks.splice(b, 1)
     }

   }
   wandFrame = requestAnimationFrame(drawBlocks)
 }

如果网页离开视图,系统会移除事件监听器并取消动画帧循环。blocks 数组也会被清除。

下面是光标轨迹的实际效果!

无障碍功能审核

创建富有趣味的探索体验固然很好,但如果用户无法访问,就没有意义了。事实证明,Adam 在这方面的专业知识为 Chrometober 做好了发布前无障碍功能审核的准备,这发挥了极大的作用。

其中涵盖的一些值得注意的方面:

  • 确保使用的 HTML 具有语义。这包括使用适当的地标元素(例如图书的 <main>);为每个内容块使用 <article> 元素;以及在介绍首字母缩写词时使用 <abbr> 元素。在制作这本书时,我们就考虑到了这些问题,因此让读者更容易上手。使用标题和链接有助于用户更轻松地浏览。为页面使用列表还意味着辅助技术会读出页面数量。
  • 确保所有图片都使用适当的 alt 属性。对于内嵌 SVG,title 元素会根据需要显示。
  • 在有助于提升体验的地方使用 aria 属性。对页面及其侧边使用 aria-label 可向用户表明其位于哪个页面。在“阅读更多”链接上使用 aria-describedBy 可显示内容块的文本。这样可以消除用户对链接将会将其引导至何处的不确定性。
  • 关于内容块,用户可以点击整个卡片,而不仅仅是“了解详情”链接。
  • 之前讨论过使用 IntersectionObserver 来跟踪哪些页面被查看。这不仅仅有助于提升性能,还有许多其他好处。系统会暂停未在视野范围内的网页的所有动画或互动。但这些网页也应用了 inert 属性。这意味着,使用屏幕阅读器的用户可以探索与视力正常的用户相同的内容。焦点仍位于当前显示的页面中,并且用户无法通过 Tab 键转到其他页面。
  • 最后但同样重要的是,我们会使用媒体查询来尊重用户对动画的偏好设置。

以下是审核中突出显示的部分措施的屏幕截图。

元素被标识为位于整本图书周围,表示它应是辅助技术用户可以找到的主要地标。More is outlined in the screenshot." width="800" height="465">

打开的 Chrometober 图书的屏幕截图。界面的各个方面都用绿色轮廓框标记出来,说明了预期的无障碍功能以及该页面将提供的用户体验结果。例如,图片包含替代文本。另一个示例是无障碍功能标签,声明视图之外的页面为休眠状态。如需了解详情,请参阅屏幕截图。

经验总结

举办 Chrometober 活动的目的不仅仅是推介社区中的 Web 内容,还让我们有机会试用正在开发的滚动关联的动画 API polyfill。

在纽约举行的团队峰会上,我们专门抽出了一个会话来测试该项目并解决出现的问题。该团队的贡献非常宝贵。这也是一个绝佳的机会,可以列出在正式发布之前需要解决的所有问题。

CSS、界面和开发者工具团队坐在会议室的桌子周围。Una 站在一块贴满便条纸的白板前。其他团队成员围坐在桌旁,手边放着饮料和笔记本电脑。

例如,在设备上测试图书时出现了渲染问题。我们的图书无法在 iOS 设备上按预期呈现。视口单位用于设置页面大小,但如果存在缺口,则会影响图书。解决方案是在 meta 视口中使用 viewport-fit=cover

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

本课程还提出了一些与 API polyfill 相关的问题。Bramus 在 polyfill 代码库中提出了这些问题。随后,他找到了这些问题的解决方案,并将其合并到 polyfill 中。例如,此拉取请求通过向部分 polyfill 添加缓存来提升了性能。

在 Chrome 中打开的演示版的屏幕截图。开发者工具已打开,并显示基准性能测量结果。

在 Chrome 中打开的演示版的屏幕截图。开发者工具已打开,并显示了经过改进的性能衡量结果。

大功告成!

这是一个非常有趣的项目,最终打造出一种奇妙的滚动体验,突出显示了社区中精彩的内容。不仅如此,它还非常适合测试 polyfill,并向工程团队提供反馈,以帮助改进 polyfill。

2022 年 Chrometober 现已结束。

希望您喜欢!您最喜欢哪项功能?欢迎给我发推文告诉我们!

Jhey 手持一张 Chrometober 角色贴纸。

如果您在活动中见到我们,或许还能从某个团队那里领取一些贴纸。

主打照片:Unsplash 用户 David Menidrey 提供