Chrometober のビルド

この Chrometober で楽しく恐ろしいヒントやコツを共有し、スクロール ブックが生まれた経緯をご紹介します。

Designcember に続き、Google はコミュニティと Chrome チームからウェブ コンテンツを紹介して共有する方法として、今年 Chrometober を構築しました。Designcember ではコンテナクエリの使用方法をご紹介しましたが、今年は CSS のスクロールリンク アニメーション API をご紹介します。

スクロールする書籍エクスペリエンスについては、web.dev/chrometober-2022 をご覧ください。

概要

このプロジェクトの目標は、スクロールリンク アニメーション API を強調する風変わりなエクスペリエンスを提供することでした。しかし、気まぐれなエクスペリエンスでは、応答性とアクセス性も必要でした。このプロジェクトは、現在開発中の API ポリフィルを試し、さまざまな手法やツールを組み合わせて試すのにも最適です。すべてはお祝いのハロウィーンのテーマから。

チーム構成は以下のようになっています。

スクロールテリング エクスペリエンスのドラフトを作成する

Chrometober のアイデアは、2022 年 5 月に最初のオフサイト チームから生まれ始めました。フリーハンドのコレクションから、ユーザーがなんらかの形でストーリーボードに沿ってスクロールする方法を考えていました。ビデオゲームから着想を得て、墓地やお化け屋敷などのシーンをスクロールする体験を考案しました。

机の上に置かれたノートには、プロジェクトに関連するさまざまな落書きやフリーハンドが置かれている。

初めての Google プロジェクトを思いがけない方向に進められる、創造的な自由を手にすることができ、とてもワクワクしました。これは、ユーザーがコンテンツ内を移動する方法の初期のプロトタイプでした。

ユーザーが横にスクロールすると、ブロックが回転してスケールインします。しかし、どうすればあらゆるサイズのデバイスで快適に利用できるかが気になるので、この考え方はやめることにしました。むしろ、過去に作成したもののデザインを参考にしました。2020 年、私は幸運にも GreenSock の ScrollTrigger にアクセスしてリリースデモをビルドできました。

私が作成したデモの 1 つが 3D-CSS の書籍で、スクロールするとページが次々と返ってきます。こちらの方が、Chrometober の要望に合っているように感じました。スクロールリンク アニメーション API は、この機能を完全に置き換えることができます。これから説明するように、scroll-snap ともうまく連携します。

プロジェクトのイラストレーター、Tyler Reed は、アイデアが変わるたびにデザインを変えることに優れていました。タイラーは、彼に投げられたクリエイティブなアイデアをすべて取り入れ、それを実現させる素晴らしい仕事をしました。いろいろとアイデアを出し合いながら、これをうまく機能させるために最も重要だったのは、特徴を分離されたブロックに分割することでした。そうした映像をシーンに合成し、生き生きとした作品に仕上げることができたのです。

構図シーンの一つに、ヘビ、両手が出てくる棺、大釜の杖を持つキツネ、不気味な顔の木、カボチャのランタンを持っているガーゴイルが描かれています。

その主なアイデアは、ユーザーが本を読み進めながら、コンテンツのブロックにアクセスできるというものでした。また、ポインタを目が追いかけているお化け屋敷のポートレート、メディアクエリによってトリガーされる微妙なアニメーションなど、エクスペリエンスに組み込まれたイースター エッグなど、気軽なちょっとしたインタラクションも行えます。これらのアイデアや機能は、スクロールするとアニメーション表示されます。初期のアイデアは、ユーザーのスクロールで上昇して X 軸に沿って並ぶゾンビウサギというものでした。

API について

個別の特集やイースター エッグで遊ぶ前に、本が必要でした。そこで、これを機に、新しい CSS のスクロールリンク アニメーション API の機能セットをテストすることにしました。現在、スクロールリンク アニメーション API はどのブラウザでもサポートされていません。API の開発中は、インタラクション チームのエンジニアがpolyfillの開発に取り組んできました。これにより、開発時に API の形状をテストできます。これは、この API を今すぐ使用できることを意味します。このような楽しいプロジェクトは、多くの場合、試験運用機能を試してフィードバックを提供するのに最適な場所です。Google が学んだことや提供できたフィードバックについては、この記事の後半をご覧ください。

大まかに言うと、この API を使用してアニメーションをスクロールにリンクできます。スクロール時にアニメーションをトリガーすることはできません。これは後でトリガーされる可能性があります。スクロールリンク アニメーションも、大きく 2 つのカテゴリに分類されます。

  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 を生成できるという利点もあります。

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 軸で平行移動して、本のように動作します。

すべてを組み合わせる

この本のメカニズムを解き明かした後は、タイラーのイラストに命を吹き込むことに集中できるようになりました。

Astro

チームは 2021 年に Designcember で Astro を使用しましたが、私は Chrometober でも再度使用したいと考えました。物事をコンポーネントに分割できる開発者の経験が、このプロジェクトに適しています。

書籍自体は構成要素です。これはページ コンポーネントのコレクションでもあります。各ページには 2 つの側面があり、それぞれに背景があります。ページ側の子は、簡単に追加、削除、配置できるコンポーネントです。

書籍の作成

ブロックを管理しやすくすることが重要でした。また、他のチームメンバーにも簡単に貢献できるようにしたいと考えました。

大まかなページは構成配列で定義します。配列内の各ページ オブジェクトは、ページのコンテンツ、背景などのメタデータを定義します。

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

ページ コンテンツ

それでは、ページの 1 つを作成してみましょう。3 ページ目には、木に飛び出したフクロウが登場します。

構成で定義されているように、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>

ここでも、ページは本質的にアトミックです。これらは一連の機能から構築されています。ページ 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" />

ページ コンポーネントをコンテンツの配置場所として使用する方法の例も紹介します。ここで、content ブロックが配置されます。

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

フクロウに関してはインタラクティブな機能で、このプロジェクトに数多くある機能の 1 つです。これは、作成した共有 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 の動作を定義するスタイル設定がもう 1 つあります。

.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()

このコードブロックでは、次の 2 つのことを行います。

  1. お客様のモーションの設定を確認します。
  2. 特に好みがなければ、フクロウのアニメーションをリンクしてスクロールします。

2 つ目のパートでは、Web Animations API を使用して、フクロウが Y 軸でアニメーションします。個々の変換プロパティ translate が使用され、1 つの 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> 要素ではメディアクエリを使用できるため、2 つの背景スタイルを指定するのに適しています。<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;
}

このポートレートが windowpointermove イベントをリッスンする方法とよく似ています。<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 でループが作成されます。カーソルは、ページが表示されているときにのみ表示されます。Google の 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 の書籍が開いているスクリーンショット。UI のさまざまな要素の周囲に緑色の枠線が引かれており、意図するユーザー補助機能と、そのページで実現するユーザー エクスペリエンスの結果が説明されています。たとえば、画像に代替テキストが設定されているとします。もう 1 つの例として、表示範囲外のページが不活性であることを宣言するユーザー補助ラベルが挙げられます。詳細はスクリーンショットをご覧ください。

振り返り

Chrometober の背後にある動機は、コミュニティのウェブ コンテンツを紹介するだけでなく、開発中のスクロールリンク アニメーション API のポリフィルを試すことでした。

ニューヨークで開催されるチームサミットでは、プロジェクトをテストして問題に取り組むためのセッションを設けました。チームの貢献は大変貴重でした。また、ライブ配信の前に取り組む必要のあることをすべて列挙する絶好の機会でもありました。

CSS、UI、DevTools のチームが会議室でテーブルを囲んでいます。付箋で覆われたホワイトボードに立つウナ。他のチームメンバーは、飲み物とノートパソコンを置いてテーブルを囲んでいる。

たとえば、デバイスで書籍をテストすると、レンダリングの問題が発生しました。iOS デバイスで書籍が想定どおりにレンダリングされません。ビューポート ユニットによってページのサイズが変更されますが、ノッチがある場合は書籍に影響を及ぼしていました。この問題は、meta ビューポートで viewport-fit=cover を使用することで解決できました。

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

このセッションでは、API ポリフィルにも問題が生じました。Bramus は、ポリフィル リポジトリでこれらの問題を提起しました。その後、これらの問題に対する解決策を見つけて、ポリフィルに統合しました。たとえば、この pull リクエストでは、ポリフィルの一部にキャッシュを追加することでパフォーマンスが向上しています。

Chrome で開いたデモのスクリーンショット。デベロッパー ツールが開き、ベースラインのパフォーマンス測定値が表示されている。

Chrome で開いたデモのスクリーンショット。デベロッパー ツールが開き、パフォーマンスの測定精度が向上している様子。

これで作業は完了です。

このプロジェクトは実に楽しいプロジェクトで、コミュニティから寄せられた素晴らしいコンテンツを目立たせる風変わりなスクロール エクスペリエンスが実現しました。それだけでなく、ポリフィルのテストや、エンジニアリング チームにフィードバックを提供してポリフィルの改善に役立っています。

Chrometober 2022 が終了しました。

お楽しみいただけたでしょうか。お気に入りの機能は何ですか?ツイートでお知らせください。

Chrometober のキャラクターのステッカー シートを持っている Jhey。

イベントでお会いするのであれば、チームからステッカーをもらえるかもしれません。

ヒーロー写真 David Menidrey / Unsplash