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 は現在から使用できます。このような楽しいプロジェクトは、試験運用版の機能を試したり、フィードバックを提供したりするのに最適な場所です。調査結果と、Google が提供できたフィードバックについては、この記事の後半をご覧ください。

概要として、この API を使用すると、アニメーションをスクロールにリンクできます。スクロール時にアニメーションをトリガーすることはできません。これは後で追加できます。スクロールにリンクされたアニメーションも、主に次の 2 つのカテゴリに分類されます。

  1. スクロール位置に反応するものです。
  2. スクロール コンテナ内の要素の位置に反応するアニメーション。

後者を作成するには、animation-timeline プロパティを介して適用される ViewTimeline を使用します。

ViewTimeline を使用した CSS の例を次に示します。

.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 軸方向に移動して、書籍のように動作するようにしています。

すべてを組み合わせる

書籍の仕組みを理解できたので、タイラーのイラストを生き生きとさせる作業に集中できました。

天体写真

チームは 2021 年に Designcember で Astro を使用しており、私は 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 ファイルのように見えますが、フロントマターに似たコードフェンスが先頭にあります。これにより、他のコンポーネントのインポートなどを行うことができます。ページ 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 のいずれかにフクロウをリンクすることです。

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 軸上のポインタの位置が値として渡されます。目を動かしながら目の中心点を取得するため、目が複製されます。元の画像は動かず、透明で、参照用に使用されます。

次に、これらを結び付け、目の 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 配列もクリアされます。

カーソル トレイルの動作は次のとおりです。

ユーザー補助機能の審査

探索する楽しいエクスペリエンスを作成することは良いことですが、ユーザーがアクセスできない場合は意味がありません。アダムのこの分野に関する専門知識は、リリース前の Chrometober のユーザー補助に関する審査の準備に非常に役立ちました。

主な内容は次のとおりです。

  • 使用した HTML がセマンティックであることを確認します。これには、書籍の <main> などの適切なランドマーク要素、コンテンツ ブロックごとに <article> 要素を使用する、頭字語が導入される場所に <abbr> 要素を使用するなど、書籍の作成時に先を見据えて対応することで、よりアクセスしやすくなりました。見出しとリンクを使用すると、ユーザーが簡単に移動できるようになります。ページにリストを使用すると、ページ数が支援技術によって読み上げられます。
  • すべての画像で適切な alt 属性が使用されていることを確認します。インライン SVG の場合、必要な場所に title 要素が存在します。
  • エクスペリエンスを向上させる場所で aria 属性を使用する。ページとその側面に aria-label を使用すると、ユーザーに現在どのページにいるのかを通知できます。[続きを読む] リンクで aria-describedBy を使用すると、コンテンツ ブロックのテキストが伝えられます。これにより、リンク先が不明確な状態を解消できます。
  • コンテンツ ブロックについては、[続きを読む] リンクだけでなく、カード全体をクリックできるようになりました。
  • IntersectionObserver を使用して、表示中のページを追跡する方法は前述しました。これには、パフォーマンスに関連するだけでなく、多くのメリットがあります。表示されていないページでは、アニメーションや操作が一時停止されます。ただし、これらのページには inert 属性も適用されています。つまり、スクリーン リーダーを使用しているユーザーは、視覚に障がいのないユーザーと同じコンテンツを閲覧できます。フォーカスは表示されているページ内に残り、タブキーで別のページに移動することはできません。
  • 最後に、メディアクエリを使用して、ユーザーのモーション設定を尊重します。

以下は、実施されている対策の一部をハイライトした審査のスクリーンショットです。

要素が書籍全体として識別されている場合、これは支援技術のユーザーが見つけるためのメインのランドマークである必要があります。詳しくはスクリーンショットを参照してください。」 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 の写真。

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

ヒーロー写真: David MenidreyUnsplash