Tworzę Chrometobera

Jak powstała przewijana książka, która w tym Chrometoberze zawiera zabawne i przerażające porady i wskazówki.

Po Designcember chcieliśmy w tym roku stworzyć Chrometober, aby wyróżniać i udostępniać treści z internetu od społeczności i zespołu Chrome. W ramach Designcember pokazaliśmy, jak używać zapytań o kontenery, a w tym roku prezentujemy interfejs API animacji CSS powiązanych z przewijaniem.

Przewijaną książkę znajdziesz na stronie web.dev/chrometober-2022.

Przegląd

Celem projektu było stworzenie zabawnego doświadczenia, które podkreśla interfejs Scroll-linked Animations API. Mimo że gra miała być zabawna, musiała też być responsywna i dostępna. Projekt był też świetnym sposobem na przetestowanie aktywnie rozwijanego kodu polyfill interfejsu API oraz wypróbowanie różnych technik i narzędzi w połączeniu. A wszystko to w halloweenowym klimacie.

Struktura naszego zespołu wyglądała tak:

Tworzenie treści w formie scrollytellingu

Pomysły na Chrometober zaczęły się pojawiać podczas pierwszego wyjazdu integracyjnego naszego zespołu w maju 2022 r. Zbiór bazgrołów skłonił nas do zastanowienia się, jak użytkownik mógłby przewijać scenorys. Zainspirowani grami wideo, rozważaliśmy przewijanie scen, takich jak cmentarze i nawiedzony dom.

Na biurku leży notatnik z różnymi rysunkami i notatkami związanymi z projektem.

To było ekscytujące, że miałam swobodę twórczą, dzięki której mogłam poprowadzić mój pierwszy projekt Google w nieoczekiwanym kierunku. Był to wczesny prototyp sposobu, w jaki użytkownik może poruszać się po treściach.

Gdy użytkownik przewija ekran w bok, bloki obracają się i powiększają. Zrezygnowałem jednak z tego pomysłu, ponieważ nie wiedziałem, jak zapewnić użytkownikom dobre wrażenia na urządzeniach o różnych rozmiarach. Zamiast tego wykorzystałem projekt, który stworzyłem w przeszłości. W 2020 roku miałem szczęście, że mogłem korzystać z ScrollTriggera firmy GreenSock do tworzenia wersji demonstracyjnych.

Jedna z moich wersji demonstracyjnych była książką 3D-CSS, w której strony przewracały się podczas przewijania. Uznałem, że to znacznie lepiej pasuje do Chrometober. Interfejs API animacji powiązanych z przewijaniem jest idealnym zamiennikiem tej funkcji. Jak się przekonasz, dobrze współpracuje też z scroll-snap.

Ilustrator projektu, Tyler Reed, świetnie radził sobie z modyfikowaniem projektu, gdy zmienialiśmy pomysły. Tyler wykonał fantastyczną pracę, wcielając w życie wszystkie kreatywne pomysły, które mu podsuwaliśmy. Świetnie się bawiliśmy podczas burzy mózgów. W dużej mierze chcieliśmy, aby funkcje były podzielone na odizolowane bloki. Dzięki temu mogliśmy tworzyć z nich sceny, a potem wybierać, co chcemy ożywić.

Jedna ze scen kompozycji przedstawiająca węża, trumnę z wystającymi rękami, lisa z różdżką przy kociołku, drzewo z upiorną twarzą i gargulca trzymającego lampion z dyni.

Głównym założeniem było to, że użytkownik, czytając książkę, może uzyskiwać dostęp do bloków treści. Mogli też wchodzić w interakcje z zabawnymi elementami, takimi jak ukryte funkcje, które wbudowaliśmy w witrynę, np. portret w nawiedzonym domu, którego oczy podążały za wskaźnikiem, czy subtelne animacje wywoływane przez zapytania o media. Te pomysły i funkcje będą animowane podczas przewijania. Jednym z pierwszych pomysłów był królik zombie, który miał się podnosić i poruszać wzdłuż osi X, gdy użytkownik przewijał stronę.

Zapoznanie się z interfejsem API

Zanim zaczęliśmy bawić się poszczególnymi funkcjami i easter eggami, potrzebowaliśmy książki. Postanowiliśmy więc wykorzystać tę okazję do przetestowania zestawu funkcji nowego interfejsu API animacji CSS powiązanych z przewijaniem. Interfejs API animacji powiązanych z przewijaniem nie jest obecnie obsługiwany w żadnej przeglądarce. Jednak podczas tworzenia interfejsu API inżynierowie z zespołu ds. interakcji pracowali nad polyfill. Dzięki temu możesz przetestować kształt interfejsu API w trakcie jego tworzenia. Oznacza to, że możemy już dziś używać tego interfejsu API, a ciekawe projekty, takie jak ten, są często świetnym miejscem do wypróbowania funkcji eksperymentalnych i przekazania opinii. W dalszej części artykułu dowiesz się, czego się nauczyliśmy i jakie opinie udało nam się przekazać.

Ogólnie rzecz biorąc, możesz używać tego interfejsu API do łączenia animacji z przewijaniem. Pamiętaj, że nie możesz uruchomić animacji podczas przewijania – ta funkcja może pojawić się później. Animacje powiązane z przewijaniem również dzielą się na 2 główne kategorie:

  1. reagują na pozycję przewijania.
  2. Te, które reagują na pozycję elementu w kontenerze przewijania.

Aby utworzyć ten drugi, używamy ViewTimeline zastosowanego za pomocą właściwości animation-timeline.

Oto przykład użycia właściwości ViewTimeline w kodzie 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;
 }
}

Tworzymy ViewTimeline z view-timeline-name i definiujemy dla niego oś. W tym przykładzie block odnosi się do logicznego block. Animacja jest połączona z przewijaniem za pomocą właściwości animation-timeline. animation-delay i animation-end-delay (w momencie pisania tego artykułu) to sposób, w jaki definiujemy fazy.

Te fazy określają punkty, w których animacja powinna zostać połączona w odniesieniu do pozycji elementu w kontenerze przewijania. W naszym przykładzie ustawiamy rozpoczęcie animacji, gdy element wejdzie (enter 0%) do kontenera przewijania. i kończyć się, gdy zajmie 50% (cover 50%) kontenera przewijania.

Oto nasza wersja demonstracyjna w akcji:

Możesz też połączyć animację z elementem, który porusza się w obszarze widocznym na ekranie. Możesz to zrobić, ustawiając animation-timeline jako view-timeline elementu. Jest to przydatne w przypadku takich scenariuszy jak animacje list. Działanie jest podobne do animowania elementów przy wejściu za pomocą 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;
  }
}

Dzięki temu element „Mover” powiększa się, gdy wchodzi do widocznego obszaru, co powoduje obrót elementu „Spinner”.

Podczas eksperymentów odkryłem, że interfejs API bardzo dobrze współpracuje z scroll-snap. Przewijanie z przyciąganiem w połączeniu z ViewTimeline świetnie sprawdzi się przy przewracaniu stron w książce.

Prototypowanie mechaniki

Po kilku próbach udało mi się stworzyć działający prototyp książki. Aby przewracać strony książki, przewijaj w poziomie.

W wersji demonstracyjnej różne reguły są wyróżnione przerywanymi obramowaniami.

Znaczniki wyglądają mniej więcej tak:

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

Podczas przewijania strony książki się obracają, ale otwierają się lub zamykają. Zależy to od wyrównania elementów aktywujących w ramach przewijania z przyciąganiem.

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

Tym razem nie łączymy ViewTimeline w CSS, ale używamy Web Animations API w JavaScripcie. Dodatkową zaletą jest możliwość zapętlenia zestawu elementów i wygenerowania potrzebnych ViewTimeline zamiast tworzenia ich ręcznie.

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

Dla każdego aktywatora generujemy ViewTimeline. Następnie animujemy stronę powiązaną z wyzwalaczem, używając tego elementu ViewTimeline. Spowoduje to powiązanie animacji strony z przewijaniem. W naszej animacji obracamy element strony wokół osi Y, aby ją odwrócić. Przesuwamy też samą stronę na osi Z, aby zachowywała się jak książka.

Łączę wszystko w całość

Gdy opracowałem mechanizm książki, mogłem skupić się na ożywieniu ilustracji Tylera.

Astro

W 2021 roku zespół użył Astro w projekcie Designcember i chętnie wykorzystał go ponownie w Chrometober. Możliwość podziału na komponenty jest bardzo przydatna w tym projekcie.

Sama książka jest komponentem. Jest to też zbiór komponentów strony. Każda strona ma 2 strony i tła. Elementy podrzędne strony to komponenty, które można łatwo dodawać, usuwać i pozycjonować.

Tworzenie książki

Zależało mi na tym, aby blokami można było łatwo zarządzać. Chciałam też ułatwić pozostałym członkom zespołu dodawanie własnych treści.

Strony na wysokim poziomie są definiowane przez tablicę konfiguracji. Każdy obiekt strony w tablicy definiuje treść, tło i inne metadane strony.

const pages = [
  {
    front: {
      marked: true,
      content: PageTwo,
      backdrop: spreadOne,
      darkBackdrop: spreadOneDark
    },
    back: {
      content: PageThree,
      backdrop: spreadTwo,
      darkBackdrop: spreadTwoDark
    },
    aria: `page 1`
  },
  /* Obfuscated page objects */
]

Są one przekazywane do komponentu Book.

<Book pages={pages} />

Komponent Book to miejsce, w którym stosowany jest mechanizm przewijania i tworzone są strony książki. Używany jest ten sam mechanizm co w prototypie, ale udostępniamy wiele instancji ViewTimeline, które są tworzone globalnie.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

Dzięki temu możemy udostępniać osie czasu do wykorzystania w innych miejscach, zamiast tworzyć je od nowa. Omówimy to dokładniej w dalszej części szkolenia.

Kompozycja strony

Każda strona jest elementem listy:

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

Zdefiniowana konfiguracja jest przekazywana do każdej instancji Page. Strony używają funkcji slotów Astro do wstawiania treści na każdej stronie.

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

Ten kod służy głównie do konfigurowania struktury. Współtwórcy mogą pracować nad treścią książki, nie musząc w większości przypadków modyfikować tego kodu.

W tle

Zmiana koncepcji na książkę znacznie ułatwiła podział na sekcje, a każda rozkładówka książki to scena z pierwotnego projektu.

Ilustracja z książki przedstawiająca jabłoń na cmentarzu. Na cmentarzu znajduje się kilka nagrobków, a na niebie przed dużym księżycem widać nietoperza.

Ponieważ zdecydowaliśmy się na format obrazu książki, tło każdej strony mogło zawierać element graficzny. Ustawienie szerokości tego elementu na 200% i użycie object-position na podstawie strony rozwiąże problem.

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

Zawartość strony

Przyjrzyjmy się tworzeniu jednej ze stron. Na stronie 3 znajduje się sowa, która wyskakuje z drzewa.

Zostanie on wypełniony komponentem PageThree zgodnie z definicją w konfiguracji. Jest to komponent Astro (PageThree.astro). Te komponenty wyglądają jak pliki HTML, ale u góry mają blok kodu podobny do sekcji z metadanymi. Dzięki temu możemy na przykład importować inne komponenty. Komponent strony 3 wygląda tak:

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

Strony mają charakter niepodzielny. Są one tworzone na podstawie zbioru cech. Na stronie 3 znajduje się blok treści i interaktywna sowa, więc dla każdego z tych elementów jest osobny komponent.

Bloki treści to linki do treści widocznych w książce. Są one również sterowane przez obiekt konfiguracji.

{
 "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
  ]
}

Ta konfiguracja jest importowana tam, gdzie wymagane są bloki treści. Następnie odpowiednia konfiguracja bloku jest przekazywana do komponentu ContentBlock.

<ContentBlock {...contentBlocks[3]} id="four" />

Jest tu też przykład tego, jak używamy komponentu strony jako miejsca do pozycjonowania treści. Tutaj umieszczany jest blok treści.

<style is:global>
  .content-block--four {
    left: 30%;
    bottom: 10%;
  }
</style>

Jednak ogólne style bloku treści są umieszczone razem z kodem komponentu.

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

Sowa to interaktywny element – jeden z wielu w tym projekcie. To dobry, prosty przykład, który pokazuje, jak wykorzystaliśmy utworzony przez nas udostępniony ViewTimeline.

Na najwyższym poziomie nasz komponent sowy importuje kod SVG i wstawia go w kodzie za pomocą fragmentu Astro.

---
import { default as Owl } from '../Features/Owl.svg?raw'
---
<Fragment set:html={Owl} />

Style pozycjonowania sowy są umieszczone w kodzie komponentu.

.owl {
  width: 34%;
  left: 10%;
  bottom: 34%;
}

Jest jeszcze jeden element stylizacji, który definiuje zachowanie transform sowy.

.owl__owl {
  transform-origin: 50% 100%;
  transform-box: fill-box;
}

Użycie transform-box wpływa na transform-origin. Sprawia, że jest on względny względem ramki ograniczającej obiektu w pliku SVG. Sowa powiększa się od dołu, dlatego użyto transform-origin: 50% 100%.

Najciekawsza część to połączenie sowy z jednym z wygenerowanych przez nas 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()

W tym bloku kodu wykonujemy 2 czynności:

  1. Sprawdź preferencje użytkownika dotyczące ruchu.
  2. Jeśli nie mają preferencji, połącz animację sowy z przewijaniem.

W drugiej części sowa animuje się na osi Y za pomocą interfejsu Web Animations API. Używana jest właściwość przekształcenia indywidualnego translate, która jest połączona z jedną właściwością ViewTimeline. Jest połączony z CHROMETOBER_TIMELINES[1] za pomocą właściwości timeline. To jest ViewTimeline, który jest generowany podczas przewracania stron. Spowoduje to powiązanie animacji sowy z przewracaniem strony za pomocą fazy enter. Określa, że gdy strona jest obrócona o 80%, sowa zaczyna się poruszać. Przy 90% sowa powinna zakończyć tłumaczenie.

Funkcje książek

Znasz już podejście do tworzenia strony i sposób działania architektury projektu. Możesz zobaczyć, jak pozwala on współpracownikom na dołączenie i pracę nad wybraną stroną lub funkcją. Różne funkcje w książce mają animacje powiązane z przewracaniem stron, np. nietoperz, który wlatuje i wylatuje podczas przewracania stron.

Zawiera też elementy, które są obsługiwane przez animacje CSS.

Gdy bloki treści były już w książce, nadszedł czas na kreatywne wykorzystanie innych funkcji. Dzięki temu mogliśmy wygenerować różne interakcje i wypróbować różne sposoby wdrażania.

Szybkie reagowanie

Jednostki widocznego obszaru określają rozmiar książki i jej funkcji. Jednak zachowanie elastyczności czcionek było ciekawym wyzwaniem. W tym przypadku dobrze sprawdzą się jednostki zapytań o kontener. Nie są one jednak jeszcze wszędzie obsługiwane. Rozmiar książki jest ustawiony, więc nie potrzebujemy zapytania o kontener. Jednostkę zapytania o kontener wbudowany można wygenerować za pomocą CSS calc() i użyć jej do określenia rozmiaru czcionki.


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

Dynie świecące w nocy

Jeśli masz wprawne oko, być może udało Ci się zauważyć, że we wcześniejszej części artykułu, w której omawialiśmy tła stron, użyliśmy elementów <source>. Una chciała, aby interakcja reagowała na preferencje dotyczące schematu kolorów. Dzięki temu tła obsługują zarówno tryb jasny, jak i ciemny w różnych wariantach. Ponieważ z elementem <picture> możesz używać zapytań o multimedia, jest to świetny sposób na udostępnienie 2 stylów tła. Element <source> sprawdza preferencje dotyczące schematu kolorów i wyświetla odpowiednie tło.

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

Możesz wprowadzić inne zmiany na podstawie preferencji dotyczących schematu kolorów. Dynie na stronie 2 reagują na preferencje użytkownika dotyczące schematu kolorów. Użyty plik SVG zawiera okręgi, które reprezentują płomienie. W trybie ciemnym są one powiększane i animowane.

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

Czy ten portret Cię obserwuje?

Jeśli spojrzysz na stronę 10, możesz coś zauważyć. Ktoś Cię obserwuje! Oczy portretu będą podążać za wskaźnikiem, gdy będziesz poruszać się po stronie. Sztuczka polega na zmapowaniu lokalizacji wskaźnika na wartość translacji i przekazaniu jej do 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)
 }

Ten kod przyjmuje zakresy wejściowe i wyjściowe oraz mapuje podane wartości. Na przykład w tym przypadku wartość wynosi 625.

mapRange(0, 100, 250, 1000, 50) // 625

W przypadku portretu wartością wejściową jest punkt środkowy każdego oka plus lub minus pewna odległość w pikselach. Zakres wyjściowy to zakres, w jakim oczy mogą się przemieszczać (w pikselach). Pozycja wskaźnika na osi X lub Y jest przekazywana jako wartość. Aby uzyskać punkt centralny oczu podczas ich poruszania, oczy są duplikowane. Oryginały nie są przenoszone, są przezroczyste i służą jako odniesienie.

Następnie należy połączyć te elementy i zaktualizować wartości niestandardowych właściwości CSS oczu, aby mogły się poruszać. Funkcja jest powiązana ze zdarzeniem pointermove w przypadku window. Gdy to nastąpi, granice każdego oka zostaną użyte do obliczenia punktów środkowych. Następnie pozycja wskaźnika jest mapowana na wartości, które są ustawione jako wartości właściwości niestandardowych oczu.

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

Gdy wartości zostaną przekazane do CSS, style mogą z nimi zrobić, co chcą. Najlepsze jest to, że możesz użyć CSS clamp(), aby każde oko zachowywało się inaczej, bez konieczności ponownego modyfikowania kodu 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);
 }

rzucanie zaklęć,

Jeśli spojrzysz na stronę 6, czy czujesz się oczarowany? Ta strona jest poświęcona projektowi naszego fantastycznego magicznego lisa. Jeśli poruszasz wskaźnikiem, możesz zobaczyć niestandardowy efekt śladu kursora. Wykorzystuje animację Canvas. Element <canvas> znajduje się nad pozostałą zawartością strony z pointer-events: none. Oznacza to, że użytkownicy mogą nadal klikać bloki treści pod nakładką.

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

Podobnie jak w przypadku portretu, który nasłuchuje zdarzenia pointermove w window, tak samo jest z elementem <canvas>. Jednak za każdym razem, gdy zdarzenie jest wywoływane, tworzymy obiekt do animowania elementu <canvas>. Te obiekty reprezentują kształty używane w śladzie kursora. Mają współrzędne i losowy odcień.

Ponownie używamy funkcji mapRange, ponieważ możemy jej użyć do zmapowania zmiany wskaźnika na size i rate. Obiekty są przechowywane w tablicy, która jest zapętlana, gdy obiekty są rysowane w elemencie <canvas>. Właściwości każdego obiektu wskazują elementowi <canvas>, gdzie należy narysować dany obiekt.

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)

Do rysowania na obszarze roboczym tworzona jest pętla za pomocą requestAnimationFrame. Ślad kursora powinien być renderowany tylko wtedy, gdy strona jest widoczna. Mamy IntersectionObserver, który aktualizuje i określa, które strony są widoczne. Jeśli strona jest widoczna, obiekty są renderowane jako okręgi na obszarze roboczym.

Następnie przechodzimy przez tablicę blocks i rysujemy każdą część śladu. Każda klatka zmniejsza rozmiar i zmienia pozycję obiektu o rate. W ten sposób uzyskujemy efekt spadania i skalowania. Jeśli obiekt zmniejszy się całkowicie, zostanie usunięty z tablicy 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)
 }

Jeśli strona zniknie z widoku, detektory zdarzeń zostaną usunięte, a pętla klatek animacji zostanie anulowana. Tablica blocks również zostanie wyczyszczona.

Oto ślad kursora w akcji.

Weryfikacja ułatwień dostępu

Stworzenie ciekawej przestrzeni do odkrywania to świetna sprawa, ale nie ma sensu, jeśli nie jest ona dostępna dla użytkowników. Doświadczenie Adama w tym obszarze okazało się nieocenione podczas przygotowywania Chrometober do sprawdzenia pod kątem ułatwień dostępu przed premierą.

Oto niektóre z najważniejszych obszarów:

  • Upewniliśmy się, że używany kod HTML jest semantyczny. Obejmuje to odpowiednie elementy punktów orientacyjnych, takie jak <main> w przypadku książki, a także użycie elementu <article> w każdym bloku treści i elementów <abbr>, w których wprowadzane są akronimy. Myślenie o przyszłości podczas tworzenia książki ułatwiło dostęp do niej. Użycie nagłówków i linków ułatwia użytkownikowi poruszanie się po stronie. Użycie listy stron oznacza również, że liczba stron jest odczytywana przez technologię wspomagającą osoby z niepełnosprawnością.
  • Upewnij się, że wszystkie obrazy mają odpowiednie atrybuty alt. W przypadku wbudowanych plików SVG element title jest obecny tam, gdzie jest to konieczne.
  • używanie atrybutów aria tam, gdzie poprawiają one wrażenia użytkowników; Użycie aria-label w przypadku stron i ich boków informuje użytkownika, na której stronie się znajduje. Użycie atrybutu aria-describedBy w linkach „Więcej informacji” przekazuje tekst bloku treści. Dzięki temu użytkownik nie będzie miał wątpliwości, dokąd prowadzi link.
  • Jeśli chodzi o bloki treści, można kliknąć całą kartę, a nie tylko link „Więcej informacji”.
  • Wcześniej wspomnieliśmy o użyciu IntersectionObserver do śledzenia, które strony są wyświetlane. Ma to wiele zalet, które nie są związane tylko z wydajnością. Animacje i interakcje na stronach, które nie są widoczne, zostaną wstrzymane. Jednak te strony mają też zastosowany atrybut inert. Oznacza to, że użytkownicy korzystający z czytnika ekranu mogą przeglądać te same treści co użytkownicy widzący. Fokus pozostaje na stronie, która jest widoczna, a użytkownicy nie mogą przejść do innej strony za pomocą klawisza Tab.
  • Na koniec używamy zapytań o media, aby uwzględnić preferencje użytkownika dotyczące ruchu.

Oto zrzut ekranu z przeglądu, na którym widać niektóre z zastosowanych środków.

jest oznaczony jako obejmujący całą książkę, co oznacza, że powinien być głównym punktem orientacyjnym dla użytkowników technologii wspomagających osoby z niepełnosprawnością. More is outlined in the screenshot." width="800" height="465">

Zrzut ekranu przedstawiający otwartą książkę Chrometober. Różne aspekty interfejsu są otoczone zielonymi ramkami, które opisują zamierzone funkcje ułatwień dostępu i wrażenia użytkownika, jakie zapewni strona. Na przykład obrazy zawierają tekst alternatywny. Innym przykładem jest etykieta ułatwień dostępu, która informuje, że strony poza widokiem są nieaktywne. Więcej informacji znajdziesz na zrzucie ekranu.

Czego się nauczyliśmy?

Motywacją do stworzenia Chrometober było nie tylko wyróżnienie treści internetowych ze społeczności, ale też przetestowanie polyfilla interfejsu API animacji powiązanych z przewijaniem, który jest w trakcie opracowywania.

Podczas spotkania zespołu w Nowym Jorku poświęciliśmy jedną sesję na przetestowanie projektu i rozwiązanie problemów, które się pojawiły. Wkład zespołu był nieoceniony. Była to też świetna okazja, aby wymienić wszystkie kwestie, które trzeba było rozwiązać przed rozpoczęciem transmisji.

Zespół ds. CSS, UI i Narzędzi deweloperskich siedzi przy stole w sali konferencyjnej. Una stoi przy tablicy pokrytej karteczkami samoprzylepnymi. Inni członkowie zespołu siedzą przy stole z przekąskami i laptopami.

Na przykład podczas testowania książki na urządzeniach wystąpił problem z renderowaniem. Nasza książka nie będzie renderowana zgodnie z oczekiwaniami na urządzeniach z iOS. Jednostki widocznego obszaru określają rozmiar strony, ale gdy pojawiło się wycięcie, miało to wpływ na książkę. Rozwiązaniem było użycie viewport-fit=cover w widocznym obszarze meta:

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

Podczas tej sesji pojawiły się też pewne problemy z polyfill interfejsu API. Bramus zgłosił te problemy w repozytorium kodu polyfill. Następnie znalazł rozwiązania tych problemów i scalił je z polyfill. Na przykład to żądanie pull zwiększyło wydajność dzięki dodaniu buforowania do części kodu polyfill.

Zrzut ekranu przedstawiający wersję demonstracyjną otwartą w Chrome. Narzędzia dla programistów są otwarte i wyświetlają podstawowy pomiar wydajności.

Zrzut ekranu przedstawiający wersję demonstracyjną otwartą w Chrome. Narzędzia dla programistów są otwarte i pokazują ulepszony pomiar skuteczności.

To wszystko.

Praca nad tym projektem była dla nas świetną zabawą, a jej efektem jest zabawne przewijanie, które podkreśla niesamowite treści od społeczności. To nie wszystko. To rozwiązanie świetnie sprawdziło się podczas testowania kodu polyfill, a także przekazywania opinii zespołowi inżynierów, aby pomóc w ulepszaniu kodu polyfill.

Chrometober 2022 dobiegł końca.

Mamy nadzieję, że Ci się podobało. Jakie są Twoje ulubione funkcje? Napisz do mnie na Twitterze i daj nam znać.

Jhey trzyma arkusz naklejek z postaciami z Chrometober.

Jeśli spotkasz nas na jakimś wydarzeniu, możesz nawet otrzymać od nas naklejki.