Строим Chrometober!

Как мы создали книгу с прокруткой, чтобы делиться забавными и пугающими полезными советами в этот Хромотябрь.

Вслед за Designcember мы решили запустить Chrometober, чтобы делиться веб-контентом от сообщества и команды Chrome. В прошлом году мы рассказывали о запросах контейнеров, а в этом – об API анимации, связанной с прокруткой CSS.

Посмотрите, как работает прокрутка книги, на сайте web.dev/chrometober-2022.

Обзор

Целью проекта было создание причудливого опыта, подчеркивающего API анимации, связанной с прокруткой. Но, несмотря на причудливость, опыт должен был быть отзывчивым и доступным. Проект также стал отличным способом протестировать полифил API, который находится в активной разработке, а также попробовать различные методы и инструменты в сочетании. И все это в праздничной тематике Хеллоуина!

Структура нашей команды выглядела следующим образом:

Разработка скроллителлинга

Идеи для Chrometober начали появляться на нашей первой выездной встрече команды в мае 2022 года. Наброски навели нас на мысль о том, что пользователь может прокручивать раскадровку. Вдохновившись видеоиграми, мы решили сделать прокручиваемый дудл с кладбищем и домом с привидениями.

На столе лежит блокнот с различными рисунками и каракулями, связанными с проектом.

Было здорово иметь творческую свободу и направить мой первый проект в Google в неожиданное русло. Это был ранний прототип того, как пользователь может перемещаться по контенту.

Когда пользователь прокручивает страницу вбок, блоки поворачиваются и увеличиваются. Но я решила отказаться от этой идеи, потому что не была уверена, что это будет удобно для пользователей устройств с экранами разных размеров. Вместо этого я склонялся к дизайну того, что делал в прошлом. В 2020 году мне посчастливилось получить доступ к ScrollTrigger от GreenSock для создания демоверсий.

Одной из демоверсий, которые я создал, была книга в формате 3D-CSS, страницы которой переворачивались по мере прокрутки. Это показалось мне гораздо более подходящим для того, что мы хотели сделать для Chrometober. API анимации, связанной с прокруткой, идеально подходит для этой функции. Кроме того, он отлично работает с scroll-snap, как вы увидите!

Наш иллюстратор Тайлер Рид отлично справлялся с изменениями дизайна по мере того, как менялись наши идеи. Тайлер проделал фантастическую работу, воплотив в жизнь все творческие идеи, которые ему предлагали. Было очень весело придумывать идеи вместе. Мы хотели, чтобы функции были разбиты на изолированные блоки. Так мы могли составлять из них сцены, а затем выбирать, что именно воплотить в жизнь.

Одна из композиций, на которой изображены змея, гроб с торчащими из него руками, лиса с волшебной палочкой у котла, дерево с жуткой рожицей и горгулья с фонарем из тыквы.

Основная идея заключалась в том, что по мере того, как пользователь продвигался по книге, он мог получить доступ к блокам контента. Они также могли взаимодействовать с забавными элементами, включая пасхальные яйца, которые мы встроили в интерфейс. Например, портрет в доме с привидениями, глаза которого следили за указателем, или тонкие анимации, запускаемые медиазапросами. Эти идеи и функции будут анимированы при прокрутке. Одной из первых идей был кролик-зомби, который поднимался и двигался по оси X, когда пользователь прокручивал страницу.

Знакомство с API

Прежде чем начать работу над отдельными функциями и пасхалками, нам нужна была книга. Поэтому мы решили воспользоваться возможностью протестировать набор функций для нового API анимации на основе прокрутки в CSS. API анимации, связанной с прокруткой, в настоящее время не поддерживается ни в одном браузере. Однако при разработке API инженеры из команды по взаимодействиям работали над полифилом. Это позволяет тестировать API по мере его разработки. Это означает, что мы можем использовать этот API уже сегодня, а забавные проекты, подобные этому, часто являются отличным местом для опробования экспериментальных функций и предоставления обратной связи. О том, что мы узнали и какие отзывы смогли предоставить, читайте далее в статье.

В целом, вы можете использовать этот API для связывания анимации с прокруткой. Обратите внимание, что запустить анимацию при прокрутке нельзя. Возможно, эта функция появится позже. Анимации, связанные с прокруткой, также делятся на две основные категории:

  1. Объявления, которые реагируют на прокрутку.
  2. Те, которые реагируют на положение элемента в его контейнере прокрутки.

Для этого мы используем ViewTimeline, который применяется через свойство animation-timeline.

Вот как выглядит использование 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;
 }
}

Мы создаем 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;
}

На этот раз мы не подключаем ViewTimeline в CSS, а используем Web Animations API в JavaScript. Это дает дополнительное преимущество: можно перебирать набор элементов и генерировать нужные нам 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 году команда использовала 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-файлы, но в верхней части у них есть блок кода, похожий на метаданные. Это позволяет нам импортировать другие компоненты. Компонент для третьей страницы выглядит так:

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

На высоком уровне наш компонент совы импортирует SVG и встраивает его с помощью фрагмента Astro.

---
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. Если у них нет предпочтений, свяжите анимацию совы с прокруткой.

Во второй части сова анимируется по оси Y с помощью Web Animations API. Используется отдельное свойство transform translate, связанное с одним ViewTimeline. Он связан с CHROMETOBER_TIMELINES[1] через свойство timeline. Это 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>. Уна хотела, чтобы взаимодействие реагировало на предпочтения цветовой схемы. В результате фоны поддерживают как светлую, так и тёмную тему с разными вариантами. Поскольку с элементом <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>

Вы можете внести другие изменения на основе этой цветовой схемы. Тыквы на второй странице реагируют на выбранную пользователем цветовую схему. В 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 для глаз, чтобы они могли двигаться. Функция привязана к событию pointermove для window. При этом границы каждого глаза используются для расчета центральных точек. Затем положение указателя сопоставляется со значениями, которые задаются в качестве значений дополнительных параметров для глаз.

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

Заклинания

Если вы посмотрите на шестую страницу, вы почувствуете себя завороженным? На этой странице вы увидите дизайн нашего фантастического волшебного лиса. Если вы переместите указатель, то можете увидеть след от него. Для этого используется анимация Canvas. Элемент <canvas> находится над остальным контентом страницы со значением pointer-events: none. Это значит, что пользователи могут нажимать на блоки контента под ним.

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

Как и в случае с портретом, элемент <canvas> прослушивает событие pointermove в window. Однако каждый раз, когда срабатывает событие, мы создаем объект для анимации элемента <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 также очищается.

Вот как выглядит след указателя.

Проверка специальных возможностей

Создать увлекательный мир – это здорово, но он должен быть доступен пользователям. Адам помог подготовить Chrometober к проверке специальных возможностей перед выпуском.

Вот некоторые из них:

  • Использование семантической разметки HTML. В частности, в книге использовались подходящие элементы ориентиров, такие как <main>, а также элемент <article> для каждого блока контента и элементы <abbr> для аббревиатур. Заблаговременное планирование при создании книги сделало ее более доступной. Заголовки и ссылки упрощают навигацию. Использование списка для страниц также означает, что количество страниц объявляется технологией специальных возможностей.
  • Убедитесь, что для всех изображений используются подходящие атрибуты alt. Для встроенных SVG-изображений элемент title присутствует там, где это необходимо.
  • Используйте атрибуты aria, если они улучшают взаимодействие с пользователем. Использование aria-label для страниц и их сторон сообщает пользователю, на какой странице он находится. Использование aria-describedBy в ссылках "Подробнее" позволяет передавать текст блока контента. Это позволяет пользователю понять, куда ведет ссылка.
  • Теперь можно нажимать на всю карточку, а не только на ссылку "Подробнее".
  • Ранее мы упоминали, что для отслеживания страниц, которые находятся в области просмотра, используется IntersectionObserver. Это дает множество преимуществ, не связанных с производительностью. Анимация и интерактивные элементы на страницах, которые не видны, будут приостановлены. но к ним также применяется атрибут inert. Это означает, что пользователи, использующие программу чтения с экрана, могут изучать тот же контент, что и зрячие пользователи. Фокус остается на текущей странице, и пользователи не могут перейти на другую страницу с помощью клавиши Tab.
  • И наконец, мы используем медиазапросы, чтобы учитывать предпочтения пользователя в отношении движения.

Ниже приведен скриншот из отчета, на котором показаны некоторые из принятых мер.

идентифицируется как элемент, охватывающий всю книгу, и должен быть основным ориентиром для пользователей технологий специальных возможностей. More is outlined in the screenshot." width="800" height="465">

Скриншот открытой книги Chrometober. Зелеными рамками выделены различные элементы интерфейса, для которых описаны специальные возможности и то, как они будут работать. Например, у изображений есть альтернативный текст. Другой пример – метка специальных возможностей, которая сообщает, что невидимые страницы неактивны. Дополнительная информация приведена на скриншоте.

Вот что мы узнали

Целью Chrometober было не только привлечь внимание к веб-контенту от сообщества, но и протестировать полифил API анимации, связанной с прокруткой, который сейчас находится в разработке.

Во время саммита нашей команды в Нью-Йорке мы выделили время, чтобы протестировать проект и решить возникшие проблемы. Вклад команды был неоценим. Это также была отличная возможность составить список всего, что нужно было решить, прежде чем мы сможем запустить игру.

Команды CSS, UI и DevTools сидят за столом в конференц-зале. Уна стоит у доски, на которой наклеены стикеры. Другие участники команды сидят за столом с ноутбуками и напитками.

Например, при тестировании книги на устройствах возникла проблема с отображением. Наша книга не будет отображаться должным образом на iOS-устройствах. Размер страницы задается в единицах области просмотра, но вырез на экране влиял на книгу. Решением было использовать viewport-fit=cover в области просмотра meta:

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

На этой сессии также были подняты некоторые проблемы с полифилом API. Bramus поднял эти вопросы в репозитории полифилла. Затем он нашел решения этих проблем и объединил их в полифилл. Например, этот pull-запрос повысил производительность за счет добавления кеширования в часть полифила.

Скриншот демоверсии, открытой в Chrome. Открыты инструменты разработчика, в которых показаны базовые показатели эффективности.

Скриншот демоверсии, открытой в Chrome. Открытые инструменты разработчика, в которых показаны улучшенные показатели эффективности.

Готово!

Это был очень интересный проект, в результате которого получилась забавная лента с потрясающим контентом от сообщества. Кроме того, это позволило протестировать полифил и предоставить команде разработчиков отзывы, которые помогли его улучшить.

Chrometober 2022 is a wrap.

Надеемся, вам понравилось! Какая функция вам нравится больше всего? Напишите мне в Твиттере и расскажите о своих впечатлениях.

Джей держит лист со стикерами персонажей из Chrometober.

Если вы встретите нас на мероприятии, то сможете получить стикеры.