Chrometober のビルド

楽しいハロウィーン テクニックを共有するスクロール ブックが Chrometober に登場した経緯をご紹介します。

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

web.dev/chrometober-2022 で書籍のスクロール操作をご確認ください。

概要

このプロジェクトの目的は、スクロールリンク アニメーション API を活用した風変わりなエクスペリエンスを提供することです。ただ、風変わりであると同時に、エクスペリエンスにもレスポンシブかつアクセスしやすいものが必要でした。このプロジェクトは、現在開発中の API ポリフィルを試すうえでも、さまざまな手法やツールを組み合わせて試すうえでも、非常に有用な方法でした。ハロウィーンのテーマで、

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

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

スクロール テリング エクスペリエンスの下書きを作成する

Chrometober のアイデアは 2022 年 5 月にオフサイトで始まっています。落書きのコレクションから、ユーザーがストーリーボードをスクロールできる方法を思いつきました。ビデオゲームにヒントを得て、墓地や幽霊屋敷などのシーンをスクロールするエクスペリエンスを検討しました。

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

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

ユーザーが横方向にスクロールすると、ブロックが回転して拡大します。しかし、あらゆるサイズのデバイスのユーザーにこのエクスペリエンスを快適に提供できるか懸念したため、このアイデアは見送ることにしました。代わりに、過去に作ったデザインに傾倒しました。2020 年、私は幸運にも GreenSock の ScrollTrigger を使用してリリース デモを作成できました。

私が作成したデモの一つは、スクロールするとページがめくれる 3D-CSS ブックでした。これは、Chrometober で実現したいものに非常に適していると感じました。スクロールリンク アニメーション API は、その機能を完全に置き換えることができます。scroll-snap とも連携して動作します。

このプロジェクトのイラストレーターである Tyler Reed は、アイデアが変わるたびにデザインを変更してくれました。Tyler は、投げかけられる創造的なアイデアをすべて取り入れ、それを実現する素晴らしい仕事をしました。一緒にアイデアをブレインストーミングするのはとても楽しかったです。これを実現するうえで大きな役割を果たすのが、特徴量を分離されたブロックに分割することでした。こうすることで、シーンを構成し、実体化したものを選択して選ぶことができました。

蛇、腕が伸びた棺、カオスの上で杖を持ったキツネ、不気味な顔をした木、カボチャランタンを持ったガーゴイルが登場するコンポジション シーンの 1 つ。

主なアイデアは、ユーザーが書籍を読み進めていくと、コンテンツのブロックにアクセスできるようにすることです。また、エクスペリエンスに組み込まれたイースター エッグなど、ちょっとした遊び心のある要素も含まれていました。たとえば、幽霊屋敷の肖像画がユーザーのカーソルを追いかけるように目が動いたり、メディア クエリによってトリガーされる繊細なアニメーションが表示されたりしました。これらのアイデアと機能は、スクロール時にアニメーション化されます。初期のアイデアは、ユーザーがスクロールすると、上昇して X 軸に沿って移動するゾンビ バニーでした。

API の概要

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

大まかに言うと、この 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-nameViewTimeline を作成し、その軸を定義します。この例では、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 軸方向に移動して、書籍のように動作するようにしています。

すべてを組み合わせる

この本の仕組みがわかれば、Tyler のイラストに命を吹き込むことに集中することができました。

天体写真

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

書籍自体がコンポーネントです。また、ページ コンポーネントのコレクションでもあります。各ページは両面で、背景があります。ページサイドの子要素は、追加、削除、配置が簡単にできるコンポーネントです。

書籍の作成

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

ページは、構成配列によって大まかに定義されます。配列内の各ページ オブジェクトは、ページのコンテンツ、背景、その他のメタデータを定義します。

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 ファイルのように見えますが、フロントマターに似たコードフェンスが先頭にあります。これにより、他のコンポーネントのインポートなどを行うことができます。ページ 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 の使用方法を示す、小さな例として適しています。

大まかに言うと、この 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 を確認すると、何かに気付くかもしれません。監視中です。ページを移動すると、ポートレートの目の動きがポインタに追従します。ここでのポイントは、ポインタの位置を 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 軸上のポインタの位置が値として渡されます。目を動かしながら目の中心点が来るように、目を複製します。元の画像は動かず、透明で、参照用に使用されます。

次に、2 つのコードを組み合わせて、目の 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 でループが作成されます。カーソル トレイルは、ページがビュー内にある場合にのみレンダリングする必要があります。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 属性も適用されています。つまり、スクリーン リーダーを使用しているユーザーは、視覚に障がいのないユーザーと同じコンテンツを閲覧できます。フォーカスは表示されているページ内に維持され、タブキーで別のページに移動することはできません。
  • 最後に、メディアクエリを使用して、ユーザーのモーション設定を尊重します。

こちらは、対策の一部を紹介するレビューのスクリーンショットです。

要素が書籍全体として識別されている場合、これは支援技術のユーザーが見つけるための主なランドマークである必要があります。詳しくはスクリーンショットを参照してください。」 width="800" height="465">

Chrometober ブックを開いた状態のスクリーンショット。UI のさまざまな部分に緑色の枠線付きのボックスが表示され、対象となるユーザー補助機能と、ページが提供するユーザー エクスペリエンスの結果が示されます。たとえば、画像に代替テキストがあります。もう 1 つの例として、表示範囲外のページは無効であると宣言するユーザー補助ラベルがあります。スクリーンショットに詳細を記載しています。

振り返り

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 の写真。

イベントにいらっしゃった際に、チームメンバーからステッカーを受け取ることもできます。

ヒーロー写真撮影: David Menidrey(出典: Unsplash