Chrometober のビルド

今年の Chrometober で、楽しくて不気味なヒントや使い方のヒントを共有するために、スクロールする本がどのようにして生まれたのか。

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

web.dev/chrometober-2022 で、スクロールする書籍を体験してみてください。

概要

このプロジェクトの目標は、Scroll-linked Animations API を活用した、遊び心のあるエクスペリエンスを提供することでした。しかし、遊び心がありながらも、レスポンシブでアクセスしやすいエクスペリエンスが必要でした。このプロジェクトは、活発に開発が進められている API ポリフィルをテストするうえでも、さまざまな手法やツールを組み合わせて試すうえでも、非常に役立ちました。すべてハロウィーンをテーマにした楽しい内容です。

チームの構成は次のとおりです。

  • Tyler Reed: イラストとデザイン
  • Jhey Tompkins: アーキテクチャおよびクリエイティブ リード
  • Una Kravets: プロジェクト リーダー
  • Bramus Van Damme: サイトのコントリビューター
  • Adam Argyle: ユーザー補助機能のレビュー
  • Aaron Forinton: コピーライティング

スクロールテリング エクスペリエンスのドラフト

Chrometober のアイデアは、2022 年 5 月に開催された最初のチーム オフサイトで生まれました。落書きのコレクションから、ユーザーが何らかの形式のストーリーボードに沿ってスクロールする方法を考えました。ビデオゲームから着想を得て、墓地や幽霊屋敷などのシーンをスクロールするエクスペリエンスを検討しました。

デスクの上に置かれたノートに、プロジェクトに関連するさまざまな落書きやメモが書かれている。

初めての Google プロジェクトで、予想外の方向に進むクリエイティブな自由を得られたのは、とても刺激的でした。これは、ユーザーがコンテンツをどのように操作するかを示す初期のプロトタイプでした。

ユーザーが横にスクロールすると、ブロックが回転して拡大表示されます。しかし、あらゆるサイズのデバイスで優れたユーザー エクスペリエンスを実現する方法を検討した結果、このアイデアは採用しないことにしました。代わりに、過去に作ったもののデザインに傾倒しました。2020 年、私は幸運にも GreenSock の ScrollTrigger を使用してリリースデモを構築することができました。

私が作成したデモの 1 つに、スクロールするとページがめくられる 3D-CSS の本がありました。これは、Chrometober で実現したいことにぴったりだと思いました。スクロール駆動アニメーション API は、この機能の代替として最適です。また、scroll-snap とも連携します。

このプロジェクトのイラストレーターである Tyler Reed は、アイデアの変更に合わせてデザインを調整することに長けていました。タイラーは、寄せられたクリエイティブなアイデアをすべて取り入れ、形にするという素晴らしい仕事をしました。一緒にアイデアをブレインストーミングするのはとても楽しかったです。この機能の仕組みとして、機能を独立したブロックに分割することが重要でした。そうすることで、それらをシーンに組み込み、命を吹き込むものを選び出すことができました。

ヘビ、腕が飛び出している棺、大釜のそばで杖を持つキツネ、不気味な顔の木、カボチャのランタンを持つガーゴイルが描かれた合成シーン。

主なアイデアは、ユーザーが本を読み進めるにつれて、コンテンツのブロックにアクセスできるようにすることでした。また、このエクスペリエンスに組み込まれたイースター エッグなど、ちょっとした遊び心のある要素も用意しました。たとえば、お化け屋敷の肖像画の目がポインタを追うようにしたり、メディアクエリによってトリガーされる繊細なアニメーションを表示したりしました。これらのアイデアや機能は、スクロールするとアニメーションで表示されます。初期のアイデアは、ユーザーのスクロールに合わせてゾンビのウサギが立ち上がり、X 軸に沿って移動するというものでした。

API をよく理解する

個々の機能やイースター エッグを試す前に、まず本が必要でした。そこで、この機会に、新しい CSS スクロールリンク アニメーション API の機能セットをテストすることにしました。スクロール連動型アニメーション API は、現在どのブラウザでもサポートされていません。しかし、API の開発中に、インタラクション チームのエンジニアがポリフィルに取り組んできました。これにより、開発中の 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;
 }
}

ViewTimeline を view-timeline-name で作成し、その軸を定義します。この例では、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 軸方向に移動して、本のように動作させます。

すべてを組み合わせる

この本の仕組みを理解した後は、タイラーのイラストを生き生きと表現することに集中できました。

天体写真

チームは 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 ファイルのように見えますが、上部にフロントマターに似たコードフェンスがあります。これにより、他のコンポーネントのインポートなどを行うことができます。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" />

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

<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 の使用方法を示す、小さな例です。

大まかに言うと、このフクロウ コンポーネントは 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 の 1 つにフクロウをリンクする場合です。

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

ポートレートが 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 の本が開いているスクリーンショット。UI のさまざまな部分が緑色の枠線で囲まれており、想定されるユーザー補助機能と、ページが提供するユーザー エクスペリエンスの結果が説明されています。たとえば、画像には代替テキストがあります。別の例として、表示されていないページが不活性であることを宣言するユーザー補助ラベルがあります。詳細はスクリーンショットに記載されています。

振り返り

Chrometober の目的は、コミュニティのウェブ コンテンツにスポットライトを当てることだけでなく、開発中のスクロール連動アニメーション API ポリフィルをテストすることでもありました。

ニューヨークで開催されたチームサミットでは、プロジェクトをテストし、発生した問題に対処するためのセッションを設けました。チームの貢献は非常に貴重なものでした。また、本番稼働前に取り組む必要のあるすべてのことをリストアップする絶好の機会でもありました。

CSS、UI、DevTools チームが会議室のテーブルを囲んで座っている。Una が付箋でいっぱいのホワイトボードの前に立っています。他のチームメンバーは、軽食とノートパソコンを置いてテーブルを囲んでいます。

たとえば、デバイスで書籍をテストしたところ、レンダリングの問題が発生しました。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。

イベントで Google チームを見かけた場合は、ステッカーをもらえるかもしれません。