Chrometober est en cours de développement !

Découvrez comment le livre à défilement a vu le jour pour partager des conseils et astuces amusants et effrayants en ce mois de Chrometobre.

Après Designcember, nous avons voulu créer Chrometober pour vous cette année afin de mettre en avant et de partager du contenu Web de la communauté et de l'équipe Chrome. Designcember a présenté l'utilisation des requêtes de conteneur, mais cette année, nous présentons l'API CSS Scroll-Linked Animations.

Découvrez l'expérience de défilement du livre sur web.dev/chrometober-2022.

Présentation

L'objectif du projet était de proposer une expérience fantaisiste mettant en avant l'API d'animations liées au défilement. Mais, tout en étant fantaisiste, l'expérience devait également être responsive et accessible. Le projet a également été un excellent moyen de tester le polyfill d'API en cours de développement, ainsi que d'essayer différentes techniques et outils en combinaison. Et tout cela sur le thème d'Halloween !

La structure de notre équipe se présentait comme suit :

Rédiger une expérience de scrollytelling

Les idées pour Chrometober ont commencé à fuser lors de notre premier séminaire d'équipe en mai 2022. Une collection de gribouillages nous a fait réfléchir à des façons dont un utilisateur pourrait faire défiler une sorte de storyboard. En nous inspirant des jeux vidéo, nous avons envisagé une expérience de défilement à travers des scènes telles que des cimetières et une maison hantée.

Un carnet de notes est posé sur un bureau, avec divers gribouillages et notes liés au projet.

C'était passionnant d'avoir la liberté créative d'emmener mon premier projet Google dans une direction inattendue. Il s'agissait d'un premier prototype de la façon dont un utilisateur pourrait naviguer dans le contenu.

Lorsque l'utilisateur fait défiler l'écran latéralement, les blocs pivotent et s'agrandissent. Mais j'ai décidé de m'éloigner de cette idée, car je me demandais comment nous pourrions offrir une expérience de qualité aux utilisateurs sur des appareils de toutes tailles. À la place, je me suis inspiré d'un design que j'avais créé par le passé. En 2020, j'ai eu la chance d'avoir accès à ScrollTrigger de GreenSock pour créer des démonstrations de versions.

L'une des démos que j'avais créées était un livre en 3D-CSS dont les pages tournaient lorsque vous faisiez défiler l'écran. Cela semblait beaucoup plus approprié pour ce que nous voulions pour Chrometober. L'API d'animations liées au défilement est une alternative idéale à cette fonctionnalité. Comme vous allez le voir, il fonctionne également très bien avec scroll-snap.

Notre illustrateur pour le projet, Tyler Reed, a su adapter le design au fur et à mesure que nos idées évoluaient. Tyler a fait un travail fantastique en prenant toutes les idées créatives qui lui ont été proposées et en leur donnant vie. C'était très amusant de réfléchir à des idées ensemble. Pour que cela fonctionne, nous voulions que les fonctionnalités soient divisées en blocs isolés. De cette façon, nous pouvions les composer en scènes, puis choisir ce que nous voulions animer.

Une des scènes de la composition représentant un serpent, un cercueil d'où sortent des bras, un renard avec une baguette magique devant un chaudron, un arbre au visage effrayant et une gargouille tenant une lanterne en forme de citrouille.

L'idée principale était que, au fur et à mesure que l'utilisateur parcourait le livre, il pouvait accéder à des blocs de contenu. Ils pouvaient également interagir avec des éléments fantaisistes, y compris les easter eggs que nous avions intégrés à l'expérience, comme un portrait dans une maison hantée dont les yeux suivaient le pointeur, ou des animations subtiles déclenchées par des requêtes média. Ces idées et fonctionnalités seraient animées lors du défilement. L'une des premières idées était un lapin zombie qui se levait et se déplaçait le long de l'axe X lorsque l'utilisateur faisait défiler la page.

Se familiariser avec l'API

Avant de pouvoir commencer à jouer avec les différentes fonctionnalités et les easter eggs, nous avions besoin d'un livre. Nous avons donc décidé de profiter de cette occasion pour tester l'ensemble de fonctionnalités de la nouvelle API CSS Scroll-Linked Animations. L'API d'animations liées au défilement n'est actuellement compatible avec aucun navigateur. Cependant, lors du développement de l'API, les ingénieurs de l'équipe chargée des interactions ont travaillé sur un polyfill. Cela permet de tester la forme de l'API au fur et à mesure de son développement. Cela signifie que nous pourrions utiliser cette API dès aujourd'hui. Les projets amusants de ce type sont souvent un excellent moyen d'essayer des fonctionnalités expérimentales et de donner votre avis. Découvrez ce que nous avons appris et les commentaires que nous avons pu fournir plus loin dans l'article.

En gros, vous pouvez utiliser cette API pour lier des animations au défilement. Il est important de noter que vous ne pouvez pas déclencher une animation lors du défilement. Cette fonctionnalité pourrait être ajoutée ultérieurement. Les animations liées au défilement se divisent également en deux catégories principales :

  1. Ceux qui réagissent à la position de défilement.
  2. Celles qui réagissent à la position d'un élément dans son conteneur de défilement.

Pour créer ce dernier, nous utilisons un ViewTimeline appliqué via une propriété animation-timeline.

Voici un exemple d'utilisation de ViewTimeline en 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;
 }
}

Nous créons un ViewTimeline avec view-timeline-name et définissons son axe. Dans cet exemple, block fait référence à l'opérateur logique block. L'animation est associée au défilement avec la propriété animation-timeline. animation-delay et animation-end-delay (au moment de la rédaction de ce document) sont les phases que nous définissons.

Ces phases définissent les points auxquels l'animation doit être associée par rapport à la position d'un élément dans son conteneur de défilement. Dans notre exemple, nous indiquons que l'animation doit démarrer lorsque l'élément entre (enter 0%) dans le conteneur de défilement. et se terminer lorsqu'elle a couvert 50 % (cover 50%) du conteneur de défilement.

Voici notre démonstration en action :

Vous pouvez également associer une animation à l'élément qui se déplace dans la fenêtre d'affichage. Pour ce faire, définissez animation-timeline sur l'élément view-timeline. Cette option est utile pour les scénarios tels que les animations de liste. Le comportement est semblable à la façon dont vous pouvez animer des éléments à l'entrée à l'aide de 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;
  }
}

Ainsi,"Mover" s'agrandit lorsqu'il entre dans la fenêtre d'affichage, ce qui déclenche la rotation de "Spinner".

En faisant des tests, j'ai constaté que l'API fonctionnait très bien avec scroll-snap. Le défilement scroll-snap combiné à ViewTimeline serait idéal pour l'alignement des pages d'un livre.

Prototypage des mécanismes

Après quelques essais, j'ai réussi à faire fonctionner un prototype de livre. Vous faites défiler l'écran horizontalement pour tourner les pages du livre.

Dans la démonstration, vous pouvez voir les différents déclencheurs mis en évidence par des bordures en pointillés.

Le balisage ressemble à ceci :

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

Lorsque vous faites défiler la page, les pages du livre se tournent, mais s'ouvrent ou se ferment brusquement. Cela dépend de l'alignement scroll-snap des déclencheurs.

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

Cette fois, nous ne connectons pas le ViewTimeline en CSS, mais nous utilisons l'API Web Animations en JavaScript. Cela présente l'avantage supplémentaire de pouvoir effectuer une boucle sur un ensemble d'éléments et de générer les ViewTimeline dont nous avons besoin, au lieu de les créer chacun manuellement.

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

Pour chaque déclencheur, nous générons un ViewTimeline. Ensuite, nous animons la page associée au déclencheur à l'aide de ce ViewTimeline. Cela associe l'animation de la page au défilement. Pour notre animation, nous faisons pivoter un élément de la page sur l'axe Y pour tourner la page. Nous traduisons également la page elle-même sur l'axe Z pour qu'elle se comporte comme un livre.

Synthèse

Une fois que j'ai eu trouvé le mécanisme du livre, j'ai pu me concentrer sur l'animation des illustrations de Tyler.

Astro

L'équipe a utilisé Astro pour Designcember en 2021, et j'avais hâte de l'utiliser à nouveau pour Chrometober. L'expérience des développeurs, qui peuvent diviser les éléments en composants, est bien adaptée à ce projet.

Le livre lui-même est un composant. Il s'agit également d'une collection de composants de page. Chaque page comporte deux côtés, et chacun d'eux possède un arrière-plan. Les enfants d'un côté de page sont des composants qui peuvent être ajoutés, supprimés et positionnés facilement.

Créer un livre

Il était important pour moi de rendre les blocs faciles à gérer. Je voulais également que le reste de l'équipe puisse facilement apporter des contributions.

Les pages de haut niveau sont définies par un tableau de configuration. Chaque objet de page du tableau définit le contenu, l'arrière-plan et d'autres métadonnées d'une page.

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

Ils sont transmis au composant Book.

<Book pages={pages} />

Le composant Book est l'endroit où le mécanisme de défilement est appliqué et où les pages du livre sont créées. Le même mécanisme que celui du prototype est utilisé, mais nous partageons plusieurs instances de ViewTimeline créées de manière globale.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

De cette façon, nous pouvons partager les chronologies pour les utiliser ailleurs au lieu de les recréer. Nous reviendrons sur ce point.

Composition de la page

Chaque page est un élément de liste dans une liste :

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

La configuration définie est transmise à chaque instance Page. Les pages utilisent la fonctionnalité d'emplacement d'Astro pour insérer du contenu dans chaque page.

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

Ce code sert principalement à configurer la structure. Les contributeurs peuvent travailler sur le contenu du livre sans avoir à toucher à ce code.

Backdrops

Le passage à un livre a facilité la division des sections, et chaque double page du livre est une scène tirée du design d'origine.

Illustration d'une double page du livre représentant un pommier dans un cimetière. Le cimetière comporte plusieurs pierres tombales, et une chauve-souris vole dans le ciel devant une grande lune.

Comme nous avions choisi un format pour le livre, l'arrière-plan de chaque page pouvait comporter un élément d'image. Pour ce faire, définissez la largeur de cet élément sur 200 % et utilisez object-position en fonction du côté de la page.

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

Contenu de la page

Voyons comment créer l'une des pages. La page 3 présente un hibou qui apparaît dans un arbre.

Il est renseigné avec un composant PageThree, tel que défini dans la configuration. Il s'agit d'un composant Astro (PageThree.astro). Ces composants ressemblent à des fichiers HTML, mais ils comportent un bloc de code en haut, semblable à un frontmatter. Cela nous permet d'effectuer des opérations telles que l'importation d'autres composants. Le composant de la page 3 se présente comme suit :

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

Encore une fois, les pages sont de nature atomique. Ils sont créés à partir d'un ensemble de caractéristiques. La page 3 comporte un bloc de contenu et la chouette interactive. Il existe donc un composant pour chacun d'eux.

Les blocs de contenu sont les liens vers le contenu du livre. Ils sont également pilotés par un objet de configuration.

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

Cette configuration est importée là où des blocs de contenu sont requis. La configuration de bloc correspondante est ensuite transmise au composant ContentBlock.

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

Voici également un exemple d'utilisation du composant de la page pour positionner le contenu. Ici, un bloc de contenu est positionné.

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

Toutefois, les styles généraux d'un bloc de contenu sont colocalisés avec le code du composant.

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

Quant à notre hibou, il s'agit d'une fonctionnalité interactive, l'une des nombreuses de ce projet. Voici un petit exemple qui montre comment nous avons utilisé la ViewTimeline partagée que nous avons créée.

En gros, notre composant Owl importe un SVG et l'intègre à l'aide du fragment d'Astro.

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

Les styles permettant de positionner notre hibou sont situés au même endroit que le code du composant.

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

Il existe un élément de style supplémentaire qui définit le comportement transform de la chouette.

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

L'utilisation de transform-box affecte le transform-origin. Elle le rend relatif au cadre de délimitation de l'objet dans le fichier SVG. La chouette s'agrandit à partir du centre inférieur, d'où l'utilisation de transform-origin: 50% 100%.

La partie amusante consiste à associer le hibou à l'un de nos ViewTimeline générés :

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

Dans ce bloc de code, nous effectuons deux actions :

  1. Vérifiez les préférences de l'utilisateur concernant les mouvements.
  2. Si elle n'a pas de préférence, associez une animation de la chouette au défilement.

Pour la deuxième partie, la chouette s'anime sur l'axe Y à l'aide de l'API Web Animations. La propriété de transformation individuelle translate est utilisée et associée à un ViewTimeline. Il est associé à CHROMETOBER_TIMELINES[1] via la propriété timeline. Il s'agit d'un ViewTimeline généré pour les changements de page. Cela associe l'animation de la chouette au changement de page à l'aide de la phase enter. Il définit que, lorsque la page est tournée à 80 %, le hibou commence à bouger. À 90 %, le hibou doit terminer sa traduction.

Fonctionnalités des livres

Vous avez vu comment créer une page et comment fonctionne l'architecture du projet. Vous pouvez voir comment il permet aux contributeurs d'intervenir et de travailler sur une page ou une fonctionnalité de leur choix. Plusieurs éléments du livre sont animés lorsque l'utilisateur tourne les pages, comme la chauve-souris qui apparaît et disparaît.

Il comporte également des éléments optimisés par des animations CSS.

Une fois les blocs de contenu intégrés au livre, il était temps de faire preuve de créativité avec d'autres fonctionnalités. Cela m'a permis de générer différentes interactions et d'essayer différentes manières d'implémenter les choses.

Préserver la réactivité

Les unités de fenêtre d'affichage responsives dimensionnent le livre et ses fonctionnalités. Cependant, faire en sorte que les polices soient responsives a été un défi intéressant. Les unités de requête de conteneur sont bien adaptées ici. Cependant, ils ne sont pas encore acceptés partout. La taille du livre est définie. Nous n'avons donc pas besoin d'une requête de conteneur. Une unité de requête de conteneur intégrée peut être générée avec CSS calc() et utilisée pour la taille de la police.


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

Citrouilles qui brillent la nuit

Les plus observateurs d'entre vous auront peut-être remarqué l'utilisation d'éléments <source> lorsque nous avons parlé des arrière-plans de page précédemment. Una souhaitait une interaction qui réagisse aux préférences de jeu de couleurs. Par conséquent, les arrière-plans sont compatibles avec les modes clair et sombre, avec différentes variantes. Comme vous pouvez utiliser des requêtes média avec l'élément <picture>, c'est un excellent moyen de fournir deux styles d'arrière-plan. L'élément <source> interroge les préférences de jeu de couleurs et affiche l'arrière-plan approprié.

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

Vous pouvez introduire d'autres modifications en fonction de cette préférence de jeu de couleurs. Les citrouilles de la page 2 réagissent aux préférences de jeu de couleurs de l'utilisateur. Le fichier SVG utilisé comporte des cercles qui représentent des flammes, qui s'agrandissent et s'animent en mode sombre.

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

Ce portrait vous regarde-t-il ?

Si vous consultez la page 10, vous remarquerez peut-être quelque chose. On vous observe ! Les yeux du portrait suivront votre pointeur lorsque vous vous déplacerez sur la page. L'astuce consiste à mapper l'emplacement du pointeur à une valeur de translation et à la transmettre au 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)
 }

Ce code prend des plages d'entrée et de sortie, et mappe les valeurs données. Par exemple, cette utilisation donnerait la valeur 625.

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

Pour le portrait, la valeur d'entrée est le point central de chaque œil, plus ou moins une certaine distance en pixels. La plage de sortie correspond à la distance que les yeux peuvent parcourir en pixels. La position du pointeur sur l'axe X ou Y est ensuite transmise en tant que valeur. Pour obtenir le point central des yeux tout en les déplaçant, les yeux sont dupliqués. Les originaux ne bougent pas, sont transparents et servent de référence.

Il s'agit ensuite de lier le tout et de mettre à jour les valeurs des propriétés CSS personnalisées des yeux pour qu'ils puissent bouger. Une fonction est liée à l'événement pointermove sur l'élément window. Lorsqu'il se déclenche, les limites de chaque œil sont utilisées pour calculer les points centraux. La position du pointeur est ensuite mappée à des valeurs définies comme valeurs de propriété personnalisée sur les yeux.

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

Une fois les valeurs transmises à CSS, les styles peuvent en faire ce qu'ils veulent. L'avantage ici est d'utiliser CSS clamp() pour que le comportement soit différent pour chaque œil. Vous pouvez ainsi faire en sorte que chaque œil se comporte différemment sans toucher à nouveau au code 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);
 }

Lancer des sorts

Si vous regardez la page 6, vous sentez-vous envoûté ? Cette page présente le design de notre fantastique renard magique. Si vous déplacez votre pointeur, vous verrez peut-être un effet de traînée de curseur personnalisé. Cette option utilise l'animation Canvas. Un élément <canvas> se trouve au-dessus du reste du contenu de la page avec pointer-events: none. Cela signifie que les utilisateurs peuvent toujours cliquer sur les blocs de contenu situés en dessous.

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

Tout comme notre portrait écoute un événement pointermove sur window, notre élément <canvas> fait de même. Pourtant, chaque fois que l'événement se déclenche, nous créons un objet à animer sur l'élément <canvas>. Ces objets représentent les formes utilisées dans la traînée du curseur. Ils ont des coordonnées et une teinte aléatoire.

La fonction mapRange que nous avons vue précédemment est réutilisée, car elle nous permet de mapper le delta du pointeur sur size et rate. Les objets sont stockés dans un tableau qui est parcouru en boucle lorsque les objets sont dessinés sur l'élément <canvas>. Les propriétés de chaque objet indiquent à notre élément <canvas> où les éléments doivent être dessinés.

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)

Pour dessiner sur le canevas, une boucle est créée avec requestAnimationFrame. La traînée du curseur ne doit s'afficher que lorsque la page est visible. Nous avons un IntersectionObserver qui met à jour et détermine les pages visibles. Si une page est affichée, les objets sont représentés par des cercles sur le canevas.

Nous parcourons ensuite le tableau blocks et dessinons chaque partie de la traînée. Chaque frame réduit la taille et modifie la position de l'objet en fonction de la valeur rate. Cela produit l'effet de chute et de mise à l'échelle. Si l'objet se réduit complètement, il est supprimé du tableau 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)
 }

Si la page n'est plus visible, les écouteurs d'événements sont supprimés et la boucle d'images d'animation est annulée. Le tableau blocks est également effacé.

Voici la traînée du curseur en action !

Examen de l'accessibilité

C'est bien de créer une expérience amusante à explorer, mais cela ne sert à rien si elle n'est pas accessible aux utilisateurs. L'expertise d'Adam dans ce domaine s'est avérée précieuse pour préparer Chrometober à un examen d'accessibilité avant sa sortie.

Voici quelques-uns des principaux domaines abordés :

  • S'assurer que le code HTML utilisé est sémantique. Cela inclut des éléments de repère appropriés tels que <main> pour le livre, l'utilisation de l'élément <article> pour chaque bloc de contenu et les éléments <abbr> où les acronymes sont introduits. En anticipant les besoins lors de la création du livre, nous avons pu le rendre plus accessible. L'utilisation de titres et de liens facilite la navigation de l'utilisateur. L'utilisation d'une liste pour les pages signifie également que le nombre de pages est annoncé par la technologie d'assistance.
  • Assurez-vous que toutes les images utilisent les attributs alt appropriés. Pour les SVG intégrés, l'élément title est présent si nécessaire.
  • Utiliser des attributs aria lorsqu'ils améliorent l'expérience L'utilisation de aria-label pour les pages et leurs côtés indique à l'utilisateur sur quelle page il se trouve. L'utilisation de aria-describedBy sur les liens "En savoir plus" communique le texte du bloc de contenu. Cela lève toute ambiguïté quant à la destination du lien.
  • En ce qui concerne les blocs de contenu, il est possible de cliquer sur l'ensemble de la fiche et pas seulement sur le lien "En savoir plus".
  • Nous avons déjà évoqué l'utilisation d'un IntersectionObserver pour suivre les pages affichées. Cela présente de nombreux avantages qui ne sont pas uniquement liés aux performances. Les animations ou interactions des pages qui ne sont pas affichées seront mises en pause. Toutefois, l'attribut inert est également appliqué à ces pages. Cela signifie que les utilisateurs d'un lecteur d'écran peuvent explorer le même contenu que les utilisateurs voyants. Le curseur reste sur la page affichée, et les utilisateurs ne peuvent pas passer à une autre page à l'aide de la touche Tabulation.
  • Enfin, nous utilisons des requêtes média pour respecter les préférences de l'utilisateur en matière de mouvement.

Voici une capture d'écran de l'examen, qui met en évidence certaines des mesures en place.

est identifié comme entourant l'ensemble du livre, ce qui indique qu'il doit s'agir du repère principal que les utilisateurs de technologies d'assistance doivent trouver. More is outlined in the screenshot." width="800" height="465">

Capture d'écran du livre Chrometober ouvert. Des encadrés verts sont placés autour de différents aspects de l'UI, décrivant la fonctionnalité d'accessibilité prévue et les résultats de l'expérience utilisateur que la page fournira. Par exemple, les images ont un texte alternatif. Autre exemple : un libellé d'accessibilité indiquant que les pages hors de vue sont inertes. L'option &quot;Plus&quot; est entourée dans la capture d'écran.

Les enseignements

L'objectif de Chrometober n'était pas seulement de mettre en avant le contenu Web de la communauté, mais aussi de tester le polyfill de l'API d'animations liées au défilement en cours de développement.

Nous avons organisé une session lors de notre sommet d'équipe à New York pour tester le projet et résoudre les problèmes qui se sont présentés. La contribution de l'équipe a été inestimable. C'était aussi l'occasion de lister tout ce qu'il fallait régler avant de lancer le site.

Les équipes CSS, UI et DevTools sont assises autour d'une table dans une salle de conférence. Una se tient devant un tableau blanc couvert de notes adhésives. D'autres membres de l'équipe sont assis autour de la table avec des rafraîchissements et des ordinateurs portables.

Par exemple, tester le livre sur des appareils a révélé un problème d'affichage. Notre livre ne s'afficherait pas comme prévu sur les appareils iOS. Les unités de la fenêtre d'affichage dimensionnent la page, mais la présence d'une encoche affectait le livre. La solution consistait à utiliser viewport-fit=cover dans la fenêtre d'affichage meta :

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

Cette session a également soulevé des problèmes liés au polyfill de l'API. Bramus a signalé ces problèmes dans le dépôt du polyfill. Il a ensuite trouvé des solutions à ces problèmes et les a intégrées au polyfill. Par exemple, cette demande d'extraction a permis d'améliorer les performances en ajoutant la mise en cache à une partie du polyfill.

Capture d'écran d'une démonstration ouverte dans Chrome. Les outils pour les développeurs sont ouverts et affichent une mesure des performances de référence.

Capture d'écran d'une démonstration ouverte dans Chrome. Les outils pour les développeurs sont ouverts et affichent une mesure des performances améliorée.

Et voilà !

Ce projet a été très amusant à réaliser. Il offre une expérience de défilement fantaisiste qui met en valeur les contenus incroyables de la communauté. De plus, cela a été très utile pour tester le polyfill et fournir des commentaires à l'équipe d'ingénieurs afin de l'améliorer.

Chrometober 2022 est terminé.

Nous espérons qu'elle vous a plu. Quelle est votre fonctionnalité préférée ? Envoyez-moi un tweet pour me le dire !

Jhey tient une planche d'autocollants des personnages de Chrometober.

Vous pourrez peut-être même obtenir des autocollants auprès d'un membre de l'équipe si vous nous croisez lors d'un événement.