正在构建 Chrometober!

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

继 Designcember 之后,我们今年想为您打造 Chrometober,以此来重点介绍和分享来自社区和 Chrome 团队的 Web 内容。Designcember 展示了容器查询的用法,但今年我们将展示 CSS 滚动关联动画 API。

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

概览

该项目的目标是提供一种新奇有趣的体验,重点展示滚动关联动画 API。不过,在追求奇思妙想的同时,也需要确保体验具有响应性且易于访问。该项目也是试用正在积极开发的 API polyfill 的绝佳方式,同时还可以尝试结合使用不同的技术和工具。所有这些都融入了万圣节的节日主题!

我们的团队结构如下:

起草滚动叙事体验

Chrometober 的创意源于 2022 年 5 月的首次团队外地会议。一系列涂鸦让我们开始思考,用户如何通过滚动来浏览某种形式的分镜脚本。受电子游戏的启发,我们考虑了通过墓地和鬼屋等场景的滚动体验。

桌上放着一个笔记本,上面有各种与项目相关的涂鸦和潦草字迹。

令人兴奋的是,我拥有了创作自由,可以朝着意想不到的方向推进我的第一个 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-delay 和 animation-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,而不是手动创建每个元素。

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 的插图栩栩如生。

天文

该团队在 2021 年将 Astro 用于 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;
}

网页内容

我们来看看如何构建其中一个页面。第三页展示了一只从树上探出头来的猫头鹰。

它会填充 PageThree 组件,如配置中所定义。这是一个 Astro 组件 (PageThree.astro)。这些组件看起来像 HTML 文件,但顶部有一个代码围栏,类似于 frontmatter。这样我们就可以执行导入其他组件等操作。第 3 页的组件如下所示:

---
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>

同样,页面在本质上是原子性的。它们由一系列特征构建而成。第三页包含一个内容块和互动式猫头鹰,因此每个元素都有一个组件。

内容块是图书中显示的内容的链接。这些也由配置对象驱动。

{
 "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>

您可以根据该配色方案偏好设置引入其他更改。第 2 页上的南瓜会根据用户的配色方案偏好做出反应。所用的 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 页,可能会注意到一些问题。有人在监视你!当您在页面上移动指针时,肖像的眼睛会跟随指针移动。这里的技巧是将指针位置映射到平移值,并将其传递给 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 自定义属性值,以便眼睛可以移动。一个函数绑定到针对 window 的 pointermove 事件。触发后,系统会根据每只眼睛的边界计算中心点。然后,指针位置会映射到设置为眼睛自定义属性值的值。

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);
 }

施放法术

如果您查看第 6 页,是否会感到着迷?本页面展示了我们神奇的魔法狐狸的设计。移动指针时,您可能会看到自定义光标拖尾效果。此动画使用画布动画。<canvas> 元素位于页面其余内容之上,并带有 pointer-events: none。这意味着用户仍然可以点击下层的内容块。

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

与肖像监听 window 上的 pointermove 事件类似,<canvas> 元素也会监听该事件。然而,每次触发事件时,我们都会创建一个对象,以便在 <canvas> 元素上添加动画效果。这些对象表示光标轨迹中使用的形状。它们有坐标和随机色调。

我们再次使用之前的 mapRange 函数,因为我们可以使用它将指针增量映射到 size 和 rate。这些对象存储在一个数组中,当对象绘制到 <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 键切换到其他页面。
  • 最后但同样重要的一点是,我们使用媒体查询来尊重用户对动画效果的偏好。

以下是审核报告中的屏幕截图,其中突出显示了一些已采取的措施。

元素被标识为围绕整本书,这表明它应该是辅助技术用户要查找的主要位置标记。屏幕截图中概述了更多内容。" width="800" height="465">

Chrometober 书籍打开后的屏幕截图。界面各部分周围有绿色轮廓框,描述了该页面将提供的预期无障碍功能和用户体验。例如,图片有替代文本。另一个示例是,无障碍标签声明视野之外的页面处于非活动状态。屏幕截图中突出显示了“更多”图标。

经验总结

Chrometober 的初衷不仅是为了突出社区中的 Web 内容,也是为了测试正在开发的滚动关联 Animation 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 已经圆满结束。

希望您喜欢!您最喜欢的功能是什么?在 Twitter 上联系我,让我们知道您的想法!

Jhey 拿着一张 Chrometober 角色贴纸。

如果您在活动中看到我们,甚至可以从团队成员那里拿到一些贴纸。