Tworzę Chrometobera

Jak powstała przewijana książka z okazji dzielenia się zabawnymi i przerażającymi wskazówkami na ten temat na Chrometoberze.

W ramach pracy z kanału Projektcember chcemy w tym roku stworzyć dla Ciebie Chrometobera. Dzięki niemu będziemy mogli wyróżniać i udostępniać treści internetowe publikowane przez społeczność oraz zespół Chrome. Firma Designcember pokazała zastosowanie zapytań w kontenerze, ale w tym roku prezentujemy interfejs CSS Scroll-end API.

Zapoznaj się z funkcją przewijania książki na stronie web.dev/chrometober-2022.

Przegląd

Celem projektu było stworzenie nietuzinkowego interfejsu z wyróżnieniem interfejsu API animacji przewijanej. Chociaż było to nieco fantazyjne, usługa musi być też elastyczna i łatwa w obsłudze. Projekt okazał się również świetnym sposobem na przetestowanie polyfill API, który jest wciąż rozwijany, a także wypróbowanie różnych technik i narzędzi w połączeniu. A wszystko to w świątecznym motywie halloweenowym!

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

Jak zaprojektować funkcję przewijania treści

Pomysły na Chrometobera zaczęły pojawiać się w naszym pierwszym zespole w maju 2022 roku. Kolekcja bazgrołów pokazała nam sposoby na to, jak użytkownik może przewijać swoją ścieżkę w jakiejś formie scenorysu. Inspirując się grami wideo, wybraliśmy możliwość przeglądania takich scen jak cmentarze czy nawiedzony dom.

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

Było mi bardzo miło, jeśli brakowało mi swobody twórczej, aby zrealizować mój pierwszy projekt Google w nieoczekiwanym kierunku. Był to wczesny prototyp sposobu poruszania się użytkownika po treści.

Gdy użytkownik przewija stronę w bok, bryły obracają się i skalują do wewnątrz. Postanowiliśmy jednak zrezygnować z tej koncepcji, ponieważ nie zastanawiam się, jak możemy ulepszyć tę usługę dla użytkowników urządzeń każdej wielkości. Skupiłem się na projekcie czegoś, co zrobiłem w przeszłości. W 2020 roku miałem dostęp do narzędzia GreenSock's ScrollTrigger do tworzenia wersji demonstracyjnych.

Jedną z utworzonych przeze mnie wersji demonstracyjnych była książka 3D CSS, w której strony przewracały się podczas przewijania. Rozwiązanie wydawało mi się znacznie bardziej odpowiednie dla Chrometobera. Interfejs API animacji połączony z przewijaniem stanowi idealne uzupełnienie tej funkcji. Jak widać, dobrze działa też z scroll-snap.

Nasz ilustrator projektu, Tyler Reed, z przyjemnością zmienił projekt i jego pomysły. Tyler wykonał świetną robotę, by wcielić w życie wszystkie pomysły, które zebrał w kreatywny sposób. Wspólna burza mózgów była bardzo zabawna. Ważną częścią naszych założeń było podzielenie funkcji na osobne bloki. W ten sposób moglibyśmy skomponować je w sceny, a potem wybrać, co chcemy żyć.

Jedna ze scen kompozycji przedstawia węża, trumnę z wystającymi rękami, lisa z różdżką przy kociołku, drzewo o przerażającej twarzy i gargulca trzymającego dyniową lampion.

Głównym założeniem było to, aby w trakcie przeglądania książki użytkownik miał dostęp do bloków treści. Mogą też wchodzić w interakcje z enigmatycznymi żartami, np. z ukrytymi w gwiazdkę żartami, np. z portretem na nawiedzonym domu, z oczami, które podążają za Twoim wskaźnikiem, czy z subtelnymi animacjami uruchamianymi przez zapytania o multimedia. Te pomysły i funkcje byłyby animowane podczas przewijania. Pomysł narodził się, aby królik zombie wznosił się i przesuwał wzdłuż osi X podczas przewijania strony.

Zapoznanie się z interfejsem API

Zanim zaczęliśmy bawić się poszczególnymi funkcjami i ukrytymi wielkanocami, potrzebna była książka. Zdecydowaliśmy się więc wykorzystać tę okazję do przetestowania zestawu funkcji nowego interfejsu API do obsługi animacji przewijanej CSS. Interfejs API animacji połączony z przewijaniem nie jest obecnie obsługiwany w żadnej przeglądarce. Jednak podczas programowania interfejsu API inżynierowie z zespołu ds. interakcji pracowali nad kodem polyfill. Umożliwia to przetestowanie kształtu interfejsu API w miarę jego rozwoju. Oznacza to, że moglibyśmy korzystać z tego interfejsu API już dziś, a takie ciekawe projekty to często świetny sposób na wypróbowanie funkcji eksperymentalnych i przekazanie opinii. Aby dowiedzieć się, czego się nauczyliśmy i jakie opinie możemy przekazać, w dalszej części artykułu.

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

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

Do utworzenia tego drugiego utworzenia używamy ViewTimeline stosowanego za pomocą właściwości animation-timeline.

Oto przykład użycia atrybutu 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 element ViewTimeline z elementem view-timeline-name i określamy dla niego oś. W tym przykładzie block odnosi się do elementu logicznego block. Animacja jest połączona z możliwością przewijania za pomocą właściwości animation-timeline. Etapy są definiowane za pomocą animation-delay i animation-end-delay (w momencie tworzenia tekstu).

Fazy te określają punkty, w których animacja powinna zostać połączona w odniesieniu do pozycji elementu w przewijanym kontenerze. W naszym przykładzie mówimy o uruchamianiu animacji, gdy element wejdzie w (enter 0%) kontener przewijany. Zakończ, gdy pokryje 50% (cover 50%) kontenera przewijanego.

Oto przykład działania:

Możesz też połączyć animację z elementem poruszającym się w widocznym obszarze. Aby to zrobić, ustaw animation-timeline jako właściwość view-timeline elementu. Jest to przydatne w takich scenariuszach, jak tworzenie listy animacji. Działa to podobnie do animacji elementów po wejściu na stronę za pomocą funkcji 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 „Przesuwanie” skaluje się w górę wraz z dotarciem do widocznego obszaru, uruchamiając rotację „Spinner”.

Z eksperymentów wynika, że interfejs API działa bardzo dobrze z funkcją scroll-snap. Funkcja „Przewiń” w połączeniu z funkcją ViewTimeline idealnie nadaje się do przyciągania przewracanych stron w książce.

Prototypowanie mechaniki

Po pewnym czasie eksperymentu udało mi się uruchomić prototyp książki. Przewijając w poziomie, możesz przewracać strony książki.

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

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 obracają się, ale otwierają lub zamykają. Zależy to od wyrównania reguł do przewijania i przyciągania.

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 elementu ViewTimeline w CSS, ale używamy interfejsu Web Animations API w JavaScripcie. Dodatkową zaletą takiego rozwiązania jest to, że można zapętlić zestaw elementów i wygenerować potrzebne ViewTimeline. Nie trzeba tworzyć 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żdej reguły generujemy element ViewTimeline. Następnie animujemy stronę powiązaną z regułą, używając tego parametru ViewTimeline. Spowoduje to połączenie animacji na stronie z przewijaniem. W ramach animacji obracamy element strony na osi Y, aby obrócić stronę. Tłumaczymy też samą stronę na osi Z, dzięki czemu wygląda jak książka.

Podsumowanie

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

Astro

W 2021 roku zespół wykorzystał Astro for Designcember, więc chciałam używać jej ponownie w Chrometoberze. Do tego projektu dobrze sprawdza się doświadczenie programistów, które daje możliwość dzielenia elementów na części.

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

Tworzenie książki

Zależy mi na tym, żeby łatwo było zarządzać blokadami. Chciałam też ułatwić reszcie zespołu dodawanie treści.

Strony na najwyższym 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 służy do zastosowania mechanizmu przewijania i tworzenia stron książki. Stosowany jest ten sam mechanizm z prototypu, ale korzystamy z wielu wystąpień ViewTimeline tworzonych globalnie.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

Dzięki temu możemy udostępnić osie czasu do wykorzystania w innym miejscu, zamiast je tworzyć. Więcej na ten temat znajdziesz później.

Kompozycja strony

Każda strona jest elementem listy w obrębie 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>

a zdefiniowana konfiguracja jest przekazywana do każdego wystąpienia Page. Strony używają funkcji przedziałów Astro do wstawiania treści.

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

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

Tło

Kreatywne podejście do książki znacznie ułatwiło dzielenie sekcji, a każdy fragment książki zawiera sceny pobrane z oryginalnego projektu.

Ilustracja przedstawiająca jabłko na cmentarzu z książką. Cmentarz ma wiele nagrobków, a na niebie przed dużym księżycem widać nietoperz.

Tak jak ustaliliśmy format obrazu książki, tło każdej strony może zawierać element obrazu. Najlepiej ustawić szerokość tego elementu na 200% i użycie parametru object-position na podstawie boku strony.

.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. Trzecia strona przedstawia sową wyłaniającą się z drzewa.

Jest on wypełniany przez komponent PageThree, zgodnie z definicją w konfiguracji. Jest to komponent astro (PageThree.astro). Te komponenty wyglądają jak pliki HTML, ale mają na górze kod graniczny, podobny do tych z interfejsu frontmatter. Dzięki temu możemy na przykład importować inne komponenty. Komponent na stronie trzeciej 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 są z natury atomowe. Ich konstrukcja obejmuje zbiór funkcji. Trzecia strona zawiera blok treści i interaktywną sową, więc każda z nich ma inny komponent.

Bloki treści to linki do treści widocznych w książce. Są one również wywoływane 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, gdy wymagane jest blokowanie treści. Następnie odpowiednia konfiguracja blokowania jest przekazywana do komponentu ContentBlock.

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

Dostępny jest też przykład tego, jak wykorzystujemy komponent strony jako miejsce do umiejscowienia treści. W tym przypadku bloka treści jest ustawiana.

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

Ogólne style bloku treści znajdują się jednak w tym samym miejscu co kod 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%;
}

To jedna z wielu funkcji w tym projekcie, jeśli chodzi o sową. To miły przykład, który pokazuje, jak wykorzystujemy utworzyliśmy udostępnioną oś czasu.

Ogólnie nasz komponent Sowa importuje plik SVG i umożliwia wbudowanie go za pomocą fragmentu Astro.

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

Style pozycjonowania naszego sowy znajdują się w tym samym miejscu, z kodem komponentu.

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

Występuje dodatkowy element stylu, który określa zachowanie ptaka (transform).

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

Użycie komponentu transform-box wpływa na transform-origin. Stanie się ono względem ramki ograniczającej obiektu w SVG. Sowa skaluje się w górę od środka, dlatego użyto funkcji transform-origin: 50% 100%.

Najzabawniejsze jest to, że łączymy sową 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ź ustawienia ruchu użytkownika.
  2. Jeśli nie mają preferencji, połącz animację przedstawiającą sowę, aby przewijać.

W drugiej części sowa animuje się na osi Y za pomocą interfejsu Web Animations API. Pojedyncza właściwość przekształcenia translate jest używana i jest połączona z 1 usługą ViewTimeline. Jest połączone z usługą CHROMETOBER_TIMELINES[1] za pomocą usługi timeline. To jest wartość ViewTimeline, która jest generowana na potrzeby przewracania stron. Łączy to animację sowy z przewracaniem stron w fazie enter. Określa, że gdy strona będzie obrócona w 80%, zacznij ruszać sową. Sowa powinna zakończyć tłumaczenie przy 90%.

Funkcje książki

Znasz już metodę tworzenia strony i wiesz, jak działa architektura projektu. Możesz sprawdzić, jak umożliwia współtwórcom rozpoczynanie pracy nad wybraną przez nich stroną lub funkcją. Animacje różnych elementów książki są powiązane z przewracaniem stron, np. kij, który wlatuje i wycofuje się na przewracanie.

Zawiera też elementy korzystające z animacji CSS.

Gdy bloki treści znajdowały się w książce, można było puścić wodze fantazji i wymyślać inne funkcje. Dało to szansę na nawiązanie różnych interakcji i wypróbowanie różnych sposobów realizacji zadań.

Zapewnianie elastyczności

Elastyczne jednostki widocznego obszaru określają rozmiar książki i jej funkcje. Jednak utrzymanie elastyczności czcionek było interesującym wyzwaniem. Dobrym rozwiązaniem są jednostki zapytań o kontenery. Nie są one jednak jeszcze obsługiwane we wszystkich miejscach. Rozmiar książki jest ustawiony, więc nie potrzebujemy zapytania o kontener. Wbudowaną jednostkę zapytania można wygenerować za pomocą języka CSS calc() i użyć 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 nocą

Ci, którzy wykazali się ciekawymi uwagami, mogli wcześniej zauważyć, że podczas omawiania tła strony stosowano elementy <source>. Una zależało na interakcji, która zareagowała na preferowany schemat kolorów. Dzięki temu w przypadku różnych wariantów tła obsługują zarówno tryb jasny, jak i ciemny. Możesz używać zapytań o multimedia z elementem <picture>, dlatego warto mieć dwa style tła. Element <source> wysyła zapytanie o preferencję 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 w oparciu o preferowany schemat kolorów. Dynie na drugiej stronie reagują na preferencje użytkownika dotyczące schematu kolorów. Użyty obraz SVG zawiera okręgi symbolizujące płomienie, które skalują się w górę i są animowane w trybie ciemnym.

.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 patrzysz na ten portret?

Na stronie 10 możesz coś zauważyć. Oglądają Cię Oczy portretu będą podążać za wskaźnikiem, gdy będziesz poruszać się po stronie. Sztuczka polega na zamapowaniu lokalizacji wskaźnika na wartość Tłumacza i przekazywaniu 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 pobiera zakresy danych wejściowych i wyjściowych i mapuje podane wartości. To użycie dałoby na przykład wartość 625.

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

W przypadku portretu wartość wejściowa to punkt środkowy każdego oka plus lub minus odległość w pikselach. Zakres wyjściowy określa w pikselach, jak bardzo oczy są w stanie przełożyć. Następnie pozycja wskaźnika na osi x lub y jest przekazywana jako wartość. Aby podczas przesuwania oczu znalazły się środkowe punkty oczu, powtarzają się. Oryginały nie przesuwają się, są przezroczyste i służą do celów referencyjnych.

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

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

Po przekazaniu wartości do CSS style mogą stosować z nimi dowolne działania. Najważniejszą zaletą tego narzędzia jest skorzystanie z CSS clamp(), by dostosować zachowanie każdego oka, dzięki czemu każde oko będzie działać inaczej, nie dotykając ponownie 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ęć

Czy czytasz stronę szóstą? Czy czujesz oczarowanie? Na tej stronie przedstawiamy projekt naszego fantastycznego magicznego liska. Jeśli przesuniesz wskaźnik myszy, możesz zauważyć niestandardowy efekt śladu kursora. Używa animacji canvas. Element <canvas> znajduje się nad pozostałą częścią strony z elementem pointer-events: none. Oznacza to, że użytkownicy nadal będą mogli klikać znajdujące się pod nimi bloki treści.

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

Podobnie jak nasz portret nasłuchuje zdarzenia pointermove w dniu window, podobnie jak element <canvas>. Jednak przy każdym uruchomieniu zdarzenia tworzymy obiekt do animacji w elemencie <canvas>. Reprezentują one kształty używane na śladzie kursora. Mają współrzędne i losowy odcień.

Używana jest ponownie nasza wcześniejsza funkcja mapRange, ponieważ możemy jej użyć do mapowania delta wskaźnika na size i rate. Obiekty są przechowywane w tablicy, która jest zapętlona podczas rysowania do elementu <canvas>. Właściwości każdego obiektu wskazują element <canvas>, gdzie należy coś narysować.

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)

W przypadku rysowania w obszarze roboczym tworzona jest pętla przy użyciu funkcji requestAnimationFrame. Ścieżka kursora powinna być renderowana tylko wtedy, gdy strona jest widoczna. Dyrektywa IntersectionObserver aktualizuje się i określa, które strony są widoczne. Jeśli strona jest widoczna, obiekty są renderowane w obszarze roboczym jako okręgi.

Następnie robimy pętlę nad tablicą blocks i rysujemy każdy fragment szlaku. Każda klatka zmniejsza rozmiar i zmienia pozycję obiektu o wartość rate. Powoduje to efekt spadku i skalowania. Jeśli obiekt całkowicie się zmniejszy, 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, detektory zdarzeń zostaną usunięte, a pętla ramki animacji zostanie anulowana. Tablica blocks też zostanie wyczyszczona.

Oto ślad kursora w działaniu.

Sprawdzanie ułatwień dostępu

Korzystanie z aplikacji zapewnia użytkownikom świetną zabawę, ale nic nie szkodzi, jeśli nie są dostępne dla użytkowników. Wiedza Adama w tej dziedzinie okazała się nieoceniona, by przygotować Chrometobera do weryfikacji ułatwień dostępu przed opublikowaniem.

Oto niektóre z omówionych obszarów:

  • Upewnienie się, że użyty kod HTML jest semantyczny. Obejmuje to takie elementy jak odpowiednie punkty orientacyjne, np. <main> w przypadku książki, zastosowanie elementu <article> w każdym bloku treści i elementów <abbr>, w których wprowadzono akronimy. Spojrzenie w przyszłość dodatkowo zwiększyło dostępność tej książki. Zastosowanie nagłówków i linków ułatwia użytkownikom poruszanie się po witrynie. Używanie listy w przypadku stron oznacza również informowanie o liczbie stron przez technologię wspomagającą.
  • Upewnienie się, że wszystkie obrazy używają odpowiednich atrybutów alt. W przypadku wbudowanych plików SVG tam, gdzie to konieczne, występuje element title.
  • Zastosowanie atrybutów aria w celu poprawy wygody korzystania z aplikacji. Zastosowanie elementu aria-label w przypadku stron i ich boków informuje użytkownika, na której stronie się znajdują. Zastosowanie elementu aria-describedBy w linkach „Więcej informacji” powoduje przekazanie treści bloku treści. Dzięki temu nie będzie wątpliwości, gdzie trafi użytkownik.
  • W przypadku blokad treści dostępna jest możliwość kliknięcia całej karty, a nie tylko linku „Czytaj więcej”.
  • Użycie metody IntersectionObserver do śledzenia wyświetlanych stron pojawiało się wcześniej. Przynosi to wiele korzyści, nie tylko związanych ze skutecznością. Na stronach, które nie są widoczne, animacje i interakcje będą wstrzymane. Strony te mają jednak 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. Zaznaczenie pozostaje na widocznej stronie, a użytkownicy nie mogą przejść na inną stronę za pomocą klawisza Tab.
  • I wreszcie, używamy zapytań o media, by uwzględnić preferencje użytkownika dotyczące ruchu.

Oto zrzut ekranu z opinii, który przedstawia niektóre zastosowane środki.

jest identyfikowany na całym świecie, co oznacza, że powinien on być głównym punktem orientacyjnym, który umożliwia użytkownikom technologii wspomagających. Więcej informacji znajdziesz na zrzucie ekranu."width="800" height="465">

Zrzut ekranu z otwartą aplikacją Chrometober. Zielone pola są otoczone różnymi aspektami interfejsu i opisują zamierzone funkcje ułatwień dostępu oraz związane z wrażeniami użytkownika wyniki tej strony. Na przykład obrazy mają tekst alternatywny. Innym przykładem jest etykieta ułatwień dostępu informująca, że niewidoczne strony są bezwładne. Więcej informacji znajdziesz na zrzucie ekranu.

Czego się nauczyliśmy?

Celem Chrometober było nie tylko wyróżnienie treści internetowych społeczności, lecz także sposób na przetestowanie opracowywanego kodu polyfill w interfejsie API linków do przewijania.

Na zjeździe naszego zespołu w Nowym Jorku wybraliśmy sesję, aby przetestować projekt i rozwiązać pojawiające się problemy. Publikacja zespołu była nieoceniona. Była to także doskonała okazja, aby wymienić wszystkie rzeczy, które trzeba rozwiązać przed rozpoczęciem kampanii.

Zespół CSS, UI i Narzędzia deweloperskie siedzą wokół stołu w sali konferencyjnej. Una stoi przy tablicy przykrytej karteczkami. Pozostali członkowie zespołu siedzą przy stole, z laptopami i przekąskami.

Problem z renderowaniem występował na przykład podczas testowania książki na urządzeniach. Na urządzeniach z iOS książka nie została wyrenderowana zgodnie z oczekiwaniami. Jednostki widocznego obszaru określają rozmiar strony, ale obecność wycięcia miała wpływ na książkę. Rozwiązaniem było użycie elementu viewport-fit=cover w widocznym obszarze meta:

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

Podczas tej sesji wystąpiły też problemy z kodem polyfill w interfejsie API. Bramus zgłosił te problemy w repozytorium polyfill. Następnie znalazł rozwiązania tych problemów i połączył je z kodem polyfill. Na przykład to żądanie pull przyniosło wzrost wydajności dzięki dodaniu buforowania do części kodu polyfill.

Zrzut ekranu z otwartą wersją demonstracyjną w Chrome. Narzędzia dla deweloperów są otwarte i wyświetlają podstawowy pomiar skuteczności.

Zrzut ekranu z otwartą wersją demonstracyjną w Chrome. Narzędzia dla deweloperów są otwarte i wyświetlają ulepszony pomiar skuteczności.

Znakomicie.

Praca nad tym projektem była świetną zabawą, dzięki której użytkownicy przewijają stronę w ciekawy sposób i wyróżniają niesamowite treści od społeczności. Kod polyfill doskonale sprawdził się nie tylko w testach, ale także w przekazywaniu opinii zespołowi inżynierów, które pomogły w jego ulepszeniu.

Chrometober 2022 jest już gotowy.

Mamy nadzieję, że Ci się podobało. Jaka jest Twoja ulubiona funkcja? Powiadom mnie na Twitterze.

Jhey trzymająca arkusz naklejek z postaciami z Chrometobera.

Możesz nawet kupić naklejki od któregoś z członków zespołu, jeśli zobaczysz nas na wydarzeniu.

Baner powitalny od Davida Menidreya w Unsplash