Creo Chrometober!

Come è nato il libro a scorrimento per condividere suggerimenti utili divertenti e spaventosi questo Chrometober.

Dopo Designcember, quest'anno abbiamo voluto creare Chrometober per mettere in evidenza e condividere i contenuti web della community e del team di Chrome. Designcember ha mostrato l'utilizzo delle query container, ma quest'anno mostriamo l'API CSS per le animazioni collegate allo scorrimento.

Dai un'occhiata all'esperienza di scorrimento del libro su web.dev/chrometober-2022.

Panoramica

L'obiettivo del progetto era offrire un'esperienza stravagante che mettesse in evidenza l'API Scroll-linked Animations. Tuttavia, oltre a essere stravagante, l'esperienza doveva essere anche reattiva e accessibile. Il progetto è stato anche un ottimo modo per testare il polyfill dell'API in fase di sviluppo attivo, oltre a provare diverse tecniche e strumenti in combinazione. Il tutto con un tema festivo di Halloween.

La struttura del nostro team era la seguente:

Creazione di un'esperienza di scrollytelling

Le idee per Chrometober hanno iniziato a prendere forma durante il nostro primo offsite di team a maggio 2022. Una serie di scarabocchi ci ha fatto pensare a come un utente potrebbe scorrere una sorta di storyboard. Ispirandoci ai videogiochi, abbiamo pensato a un'esperienza di scorrimento attraverso scene come cimiteri e una casa infestata.

Un taccuino su una scrivania con vari scarabocchi e appunti relativi al progetto.

È stato entusiasmante avere la libertà creativa di portare il mio primo progetto Google in una direzione inaspettata. Questo era un primo prototipo di come un utente potrebbe navigare tra i contenuti.

Mentre l'utente scorre lateralmente, i blocchi ruotano e si ridimensionano. Tuttavia, ho deciso di abbandonare questa idea perché mi preoccupava il modo in cui avremmo potuto rendere questa esperienza ottimale per gli utenti su dispositivi di tutte le dimensioni. Invece, ho preferito il design di un oggetto che avevo realizzato in passato. Nel 2020 ho avuto la fortuna di avere accesso a ScrollTrigger di GreenSock per creare demo di release.

Una delle demo che avevo creato era un libro in CSS 3D in cui le pagine si giravano mentre scorrevi, e questo mi sembrava molto più appropriato per quello che volevamo per Chrometober. L'API Scroll-linked Animations è un'alternativa perfetta per questa funzionalità. Come vedrai, funziona bene anche con scroll-snap.

Il nostro illustratore per il progetto, Tyler Reed, è stato bravissimo a modificare il design man mano che cambiavamo idea. Tyler ha fatto un lavoro fantastico nel raccogliere tutte le idee creative che gli sono state proposte e nel dar loro vita. È stato molto divertente fare brainstorming insieme. Un aspetto importante del funzionamento che volevamo ottenere era la suddivisione delle funzionalità in blocchi isolati. In questo modo, abbiamo potuto comporli in scene e poi scegliere cosa dare vita.

Una delle scene della composizione con un serpente, una bara da cui escono delle braccia, una volpe con una bacchetta magica vicino a un calderone, un albero con una faccia spettrale e un gargoyle con una lanterna a forma di zucca.

L'idea principale era che, man mano che l'utente avanzava nel libro, poteva accedere a blocchi di contenuti. Potevano anche interagire con tocchi di fantasia, tra cui gli easter egg che avevamo integrato nell'esperienza, ad esempio un ritratto in una casa infestata, i cui occhi seguivano il puntatore, o animazioni discrete attivate dalle query supporti. Queste idee e funzionalità verrebbero animate durante lo scorrimento. Un'idea iniziale era un coniglietto zombie che si alzava e si spostava lungo l'asse X quando l'utente scorreva.

Acquisire familiarità con l'API

Prima di poter iniziare a giocare con le singole funzionalità e gli easter egg, avevamo bisogno di un libro. Abbiamo quindi deciso di trasformare questa situazione in un'opportunità per testare il set di funzionalità per l'API CSS Scroll-linked Animations emergente. L'API per le animazioni collegate allo scorrimento non è attualmente supportata in nessun browser. Tuttavia, durante lo sviluppo dell'API, gli ingegneri del team di interazioni hanno lavorato a un polyfill. In questo modo, è possibile testare la forma dell'API durante lo sviluppo. Ciò significa che potremmo utilizzare questa API oggi stesso e progetti divertenti come questo sono spesso un ottimo modo per provare funzionalità sperimentali e fornire feedback. Scopri cosa abbiamo imparato e il feedback che siamo stati in grado di fornire più avanti nell'articolo.

In generale, puoi utilizzare questa API per collegare le animazioni allo scorrimento. È importante notare che non è possibile attivare un'animazione allo scorrimento, ma questa funzionalità potrebbe essere disponibile in futuro. Anche le animazioni collegate allo scorrimento rientrano in due categorie principali:

  1. Quelli che reagiscono alla posizione di scorrimento.
  2. Quelli che reagiscono alla posizione di un elemento nel relativo container di scorrimento.

Per creare quest'ultimo, utilizziamo un ViewTimeline applicato tramite una proprietà animation-timeline.

Ecco un esempio di utilizzo di ViewTimeline in 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;
 }
}

Creiamo un ViewTimeline con view-timeline-name e ne definiamo l'asse. In questo esempio, block si riferisce alla block logica. L'animazione viene collegata allo scorrimento con la proprietà animation-timeline. animation-delay e animation-end-delay (al momento della stesura di questo articolo) sono il modo in cui definiamo le fasi.

Queste fasi definiscono i punti in cui l'animazione deve essere collegata in relazione alla posizione di un elemento nel relativo container di scorrimento. Nel nostro esempio, diciamo di avviare l'animazione quando l'elemento entra (enter 0%) nel contenitore di scorrimento. E termina quando ha coperto il 50% (cover 50%) del contenitore di scorrimento.

Ecco la nostra demo in azione:

Puoi anche collegare un'animazione all'elemento che si muove nell'area visibile. Per farlo, imposta animation-timeline come view-timeline dell'elemento. Questo è utile per scenari come le animazioni degli elenchi. Il comportamento è simile a quello che utilizzeresti per animare gli elementi all'entrata utilizzando 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;
  }
}

In questo modo,"Mover" aumenta di dimensioni quando entra nell'area visibile, attivando la rotazione di "Spinner".

Sperimentando, ho scoperto che l'API funziona molto bene con scroll-snap. L'allineamento dello scorrimento combinato con ViewTimeline sarebbe un'ottima soluzione per l'allineamento delle pagine di un libro.

Prototipazione delle meccaniche

Dopo alcuni esperimenti, sono riuscito a far funzionare un prototipo di libro. Scorri in orizzontale per sfogliare le pagine del libro.

Nella demo, puoi vedere i diversi trigger evidenziati con bordi tratteggiati.

Il markup è simile al seguente:

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

Mentre scorri, le pagine del libro girano, ma si aprono o si chiudono di scatto. Questo dipende dall'allineamento dello scorrimento dei trigger.

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

Questa volta non colleghiamo ViewTimeline in CSS, ma utilizziamo l'API Web Animations in JavaScript. Questo ha l'ulteriore vantaggio di poter eseguire un loop su un insieme di elementi e generare i ViewTimeline di cui abbiamo bisogno, invece di crearli singolarmente a mano.

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

Per ogni trigger, generiamo un ViewTimeline. Poi animiamo la pagina associata all'attivatore utilizzando ViewTimeline. In questo modo, l'animazione della pagina viene collegata allo scorrimento. Per la nostra animazione, ruotiamo un elemento della pagina sull'asse Y per girare la pagina. Inoltre, trasliamo la pagina stessa sull'asse Z in modo che si comporti come un libro.

Elaborazione della risposta…

Una volta elaborato il meccanismo del libro, ho potuto concentrarmi sul dare vita alle illustrazioni di Tyler.

Astrofotografia

Il team ha utilizzato Astro per Designcember nel 2021 e non vedevo l'ora di utilizzarlo di nuovo per Chrometober. L'esperienza di sviluppo di poter suddividere le cose in componenti è adatta a questo progetto.

Il libro stesso è un componente. È anche una raccolta di componenti della pagina. Ogni pagina ha due lati e degli sfondi. Gli elementi secondari di un lato della pagina sono componenti che possono essere aggiunti, rimossi e posizionati con facilità.

Creazione di un libro

Per me era importante rendere i blocchi facili da gestire. Volevo anche semplificare il contributo del resto del team.

Le pagine di alto livello sono definite da un array di configurazione. Ogni oggetto pagina nell'array definisce i contenuti, lo sfondo e altri metadati di una pagina.

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

Questi vengono passati al componente Book.

<Book pages={pages} />

Il componente Book è il punto in cui viene applicato il meccanismo di scorrimento e vengono create le pagine del libro. Viene utilizzato lo stesso meccanismo del prototipo, ma condividiamo più istanze di ViewTimeline create a livello globale.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

In questo modo, possiamo condividere le cronologie da utilizzare altrove anziché ricrearle. Ne parleremo meglio più avanti.

Composizione della pagina

Ogni pagina è un elemento di un elenco:

<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 configurazione definita viene passata a ogni istanza Page. Le pagine utilizzano la funzionalità slot di Astro per inserire contenuti in ogni pagina.

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

Questo codice serve principalmente per configurare la struttura. I collaboratori possono lavorare sui contenuti del libro per la maggior parte del tempo senza dover toccare questo codice.

Sfondi

Il passaggio creativo a un libro ha reso molto più semplice la suddivisione delle sezioni e ogni pagina del libro è una scena tratta dal design originale.

Illustrazione di una pagina del libro che mostra un melo in un cimitero. Il cimitero ha diverse lapidi e c'è un pipistrello nel cielo davanti a una grande luna.

Poiché avevamo deciso le proporzioni del libro, lo sfondo di ogni pagina poteva avere un elemento immagine. Impostare l'elemento su una larghezza del 200% e utilizzare object-position in base al lato della pagina è la soluzione.

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

Contenuto della pagina

Vediamo come creare una delle pagine. La pagina tre presenta un gufo che spunta da un albero.

Viene compilato con un componente PageThree, come definito nella configurazione. Si tratta di un componente Astro (PageThree.astro). Questi componenti assomigliano a file HTML, ma hanno un blocco di codice nella parte superiore simile al frontmatter. In questo modo possiamo eseguire operazioni come l'importazione di altri componenti. Il componente per la pagina tre ha il seguente aspetto:

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

Anche in questo caso, le pagine sono di natura atomica. Sono costituiti da una raccolta di caratteristiche. La pagina tre contiene un blocco di contenuti e il gufo interattivo, quindi è presente un componente per ciascuno.

I blocchi di contenuti sono i link ai contenuti visualizzati nel libro. Anche questi sono basati su un oggetto di configurazione.

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

Questa configurazione viene importata dove sono richiesti i blocchi di contenuti. Poi, la configurazione del blocco pertinente viene passata al componente ContentBlock.

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

Ecco anche un esempio di come utilizziamo il componente della pagina come posizione per i contenuti. Qui viene posizionato un blocco di contenuti.

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

Tuttavia, gli stili generali di un blocco di contenuti sono collocati insieme al codice del componente.

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

Per quanto riguarda il nostro gufo, si tratta di una funzionalità interattiva, una delle tante di questo progetto. Questo è un piccolo esempio da esaminare che mostra come abbiamo utilizzato la ViewTimeline condivisa che abbiamo creato.

In linea generale, il nostro componente gufo importa un SVG e lo inserisce in linea utilizzando il frammento di Astro.

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

Inoltre, gli stili per il posizionamento del nostro gufo sono collocati insieme al codice del componente.

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

C'è un ulteriore elemento di stile che definisce il comportamento di transform per il gufo.

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

L'utilizzo di transform-box influisce su transform-origin. Lo rende relativo al riquadro di delimitazione dell'oggetto all'interno dell'SVG. Il gufo si ingrandisce dal centro in basso, da qui l'uso di transform-origin: 50% 100%.

La parte divertente è quando colleghiamo il gufo a uno dei nostri ViewTimeline generati:

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

In questo blocco di codice, facciamo due cose:

  1. Controlla le preferenze di movimento dell'utente.
  2. Se non hanno preferenze, collega un'animazione del gufo per lo scorrimento.

Per la seconda parte, il gufo si anima sull'asse Y utilizzando l'API Web Animations. Viene utilizzata la proprietà di trasformazione individuale translate, collegata a un ViewTimeline. È collegato a CHROMETOBER_TIMELINES[1] tramite la proprietà timeline. Si tratta di un ViewTimeline generato per i cambi di pagina. In questo modo, l'animazione del gufo viene collegata al cambio di pagina utilizzando la fase enter. Definisce che, quando la pagina è girata all'80%, il gufo inizia a muoversi. Al 90%, il gufo dovrebbe completare la traduzione.

Funzionalità dei libri

Ora hai visto l'approccio per la creazione di una pagina e come funziona l'architettura del progetto. Puoi vedere come consente ai collaboratori di intervenire e lavorare su una pagina o una funzionalità di loro scelta. Le animazioni di varie funzionalità del libro sono collegate al cambio di pagina, ad esempio il pipistrello che entra ed esce quando si cambia pagina.

Inoltre, contiene elementi basati su animazioni CSS.

Una volta inseriti i blocchi di contenuti nel libro, è stato il momento di dare sfogo alla creatività con altre funzionalità. Questo ha offerto l'opportunità di generare alcune interazioni diverse e di provare modi diversi di implementare le cose.

Mantenere la reattività

Le unità dell'area visibile adattabile dimensionano il libro e le sue funzionalità. Tuttavia, mantenere i caratteri adattabili è stata una sfida interessante. Le unità di query container sono adatte in questo caso. Tuttavia, non sono ancora supportate ovunque. Le dimensioni del libro sono impostate, quindi non abbiamo bisogno di una query container. Un'unità di query container in linea può essere generata con CSS calc() e utilizzata per il dimensionamento dei caratteri.


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

Zucche che brillano di notte

Chi ha un occhio attento potrebbe aver notato l'uso degli elementi <source> quando abbiamo parlato degli sfondi delle pagine in precedenza. Una voleva un'interazione che reagisse alla preferenza della combinazione di colori. Di conseguenza, gli sfondi supportano sia la modalità Luce che la modalità Buio con diverse varianti. Poiché puoi utilizzare le query supporti con l'elemento <picture>, è un ottimo modo per fornire due stili di sfondo. L'elemento <source> esegue una query per la preferenza della combinazione di colori e mostra lo sfondo appropriato.

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

Potresti introdurre altre modifiche in base alla preferenza della combinazione di colori. Le zucche nella pagina due reagiscono alla preferenza della combinazione di colori di un utente. L'SVG utilizzato ha cerchi che rappresentano fiamme, che si ingrandiscono e si animano in modalità Buio.

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

Questo ritratto ti sta guardando?

Se dai un'occhiata alla pagina 10, potresti notare qualcosa. Ti stanno osservando. Gli occhi del ritratto seguiranno il puntatore mentre ti sposti nella pagina. Il trucco è mappare la posizione del puntatore a un valore di traslazione e passarlo al 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)
 }

Questo codice prende gli intervalli di input e output e mappa i valori specificati. Ad esempio, questo utilizzo restituirebbe il valore 625.

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

Per il ritratto, il valore di input è il punto centrale di ogni occhio, più o meno una certa distanza in pixel. L'intervallo di output indica di quanto possono spostarsi gli occhi in pixel. La posizione del puntatore sull'asse X o Y viene quindi passata come valore. Per ottenere il punto centrale degli occhi mentre li sposti, gli occhi vengono duplicati. Gli originali non si muovono, sono trasparenti e vengono utilizzati come riferimento.

Poi si tratta di collegare il tutto e aggiornare i valori delle proprietà personalizzate CSS sugli occhi in modo che possano muoversi. Una funzione è associata all'evento pointermove per window. Quando questo evento viene attivato, i limiti di ciascun occhio vengono utilizzati per calcolare i punti centrali. La posizione del puntatore viene quindi mappata ai valori impostati come valori delle proprietà personalizzate sugli occhi.

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

Una volta passati i valori al CSS, gli stili possono utilizzarli come preferiscono. La parte migliore è l'utilizzo di CSS clamp() per differenziare il comportamento di ogni occhio, in modo da poterlo fare senza toccare di nuovo 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);
 }

Lanciare incantesimi

Se dai un'occhiata a pagina sei, ti senti incantato? Questa pagina adotta il design della nostra fantastica volpe magica. Se sposti il puntatore, potresti vedere un effetto di scia del cursore personalizzato. Utilizza l'animazione canvas. Un elemento <canvas> si trova sopra il resto dei contenuti della pagina con pointer-events: none. Ciò significa che gli utenti possono comunque fare clic sui blocchi di contenuti sottostanti.

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

Proprio come il nostro ritratto ascolta un evento pointermove su window, lo stesso fa il nostro elemento <canvas>. Tuttavia, ogni volta che l'evento viene attivato, creiamo un oggetto da animare sull'elemento <canvas>. Questi oggetti rappresentano le forme utilizzate nella scia del cursore. Hanno coordinate e una tonalità casuale.

La funzione mapRange precedente viene utilizzata di nuovo, in quanto possiamo usarla per mappare il delta del puntatore a size e rate. Gli oggetti vengono archiviati in un array che viene ripetuto quando gli oggetti vengono disegnati nell'elemento <canvas>. Le proprietà di ogni oggetto indicano al nostro elemento <canvas> dove devono essere disegnati gli elementi.

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)

Per disegnare sul canvas, viene creato un loop con requestAnimationFrame. La scia del cursore deve essere visualizzata solo quando la pagina è visibile. Abbiamo un IntersectionObserver che aggiorna e determina quali pagine sono visualizzate. Se una pagina è visibile, gli oggetti vengono visualizzati come cerchi sul canvas.

Poi eseguiamo un loop sull'array blocks e disegniamo ogni parte della scia. Ogni frame riduce le dimensioni e altera la posizione dell'oggetto di rate. In questo modo si ottiene l'effetto di caduta e scalabilità. Se l'oggetto si riduce completamente, viene rimosso dall'array 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)
 }

Se la pagina non è più visibile, i listener di eventi vengono rimossi e il loop del frame di animazione viene annullato. Anche l'array blocks viene cancellato.

Ecco la scia del cursore in azione.

Revisione dell'accessibilità

Creare un'esperienza divertente da esplorare è un'ottima cosa, ma non serve a nulla se non è accessibile agli utenti. L'esperienza di Adam in questo campo si è rivelata preziosa per preparare Chrometober a una revisione dell'accessibilità prima del rilascio.

Ecco alcune delle aree principali trattate:

  • Assicurarsi che l'HTML utilizzato fosse semantico. Sono inclusi elementi di riferimento appropriati, come <main> per il libro, l'utilizzo dell'elemento <article> per ogni blocco di contenuti e gli elementi <abbr> in cui vengono introdotti gli acronimi. Pensare in anticipo alla creazione del libro ha reso le cose più accessibili. L'uso di intestazioni e link facilita la navigazione per l'utente. L'utilizzo di un elenco per le pagine significa anche che il numero di pagine viene annunciato dalle tecnologie per la disabilità.
  • Assicurati che tutte le immagini utilizzino attributi alt appropriati. Per gli SVG incorporati, l'elemento title è presente dove necessario.
  • Utilizzare gli attributi aria dove migliorano l'esperienza. L'utilizzo di aria-label per le pagine e i relativi lati comunica all'utente la pagina in cui si trova. L'utilizzo di aria-describedBy sui link "Scopri di più" comunica il testo del blocco di contenuti. In questo modo, non ci saranno dubbi su dove il link porterà l'utente.
  • Per quanto riguarda i blocchi di contenuti, è possibile fare clic sull'intera scheda e non solo sul link "Leggi di più".
  • L'utilizzo di un IntersectionObserver per monitorare quali pagine sono visualizzate è stato menzionato in precedenza. Questo approccio offre molti vantaggi che non riguardano solo le prestazioni. Le pagine non visibili avranno l'animazione o l'interazione in pausa. Tuttavia, a queste pagine è applicato anche l'attributo inert. Ciò significa che gli utenti che utilizzano uno screen reader possono esplorare gli stessi contenuti degli utenti vedenti. Lo stato attivo rimane all'interno della pagina visualizzata e gli utenti non possono passare a un'altra pagina con il tasto Tab.
  • Infine, utilizziamo le query supporti per rispettare le preferenze dell'utente in merito ai movimenti.

Ecco uno screenshot della revisione che evidenzia alcune delle misure in atto.

L'elemento è identificato come l'intero libro, il che indica che dovrebbe essere il punto di riferimento principale per gli utenti di tecnologie per la disabilità. More is outlined in the screenshot." width="800" height="465">

Screenshot del libro Chrometober aperto. Intorno ai vari aspetti della UI sono presenti riquadri con contorno verde che descrivono la funzionalità di accessibilità prevista e i risultati dell'esperienza utente che la pagina fornirà. Ad esempio, le immagini hanno un testo alternativo. Un altro esempio è un'etichetta di accessibilità che dichiara che le pagine non visibili sono inerti. Altro è evidenziato nello screenshot.

Che cosa abbiamo imparato

La motivazione alla base di Chrometober non era solo quella di mettere in evidenza i contenuti web della community, ma anche di testare il polyfill dell'API Scroll-linked Animations in fase di sviluppo.

Durante il nostro summit del team a New York, abbiamo dedicato una sessione per testare il progetto e affrontare i problemi emersi. Il contributo del team è stato prezioso. È stata anche un'ottima opportunità per elencare tutte le cose da fare prima di poter lanciare l'app.

Il team di CSS, UI e DevTools è seduto intorno al tavolo di una sala conferenze. Una persona in piedi davanti a una lavagna bianca coperta di foglietti adesivi. Altri membri del team sono seduti attorno al tavolo con bevande e laptop.

Ad esempio, durante i test del libro sui dispositivi è stato rilevato un problema di rendering. Il nostro libro non verrebbe visualizzato come previsto sui dispositivi iOS. Le unità dell'area visibile dimensionano la pagina, ma la presenza di un notch influiva sul libro. La soluzione è stata utilizzare viewport-fit=cover nell'area visibile meta:

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

Questa sessione ha anche sollevato alcuni problemi con il polyfill dell'API. Bramus ha segnalato questi problemi nel repository del polyfill. Successivamente ha trovato soluzioni a questi problemi e le ha integrate nel polyfill. Ad esempio, questa richiesta di pull ha ottenuto un miglioramento delle prestazioni aggiungendo la memorizzazione nella cache a una parte del polyfill.

Uno screenshot di una demo aperta in Chrome. Gli Strumenti per sviluppatori sono aperti e mostrano una misurazione del rendimento di base.

Uno screenshot di una demo aperta in Chrome. Gli Strumenti per sviluppatori sono aperti e mostrano una misurazione del rendimento migliorata.

È tutto.

È stato un progetto davvero divertente su cui lavorare, che ha portato a un'esperienza di scorrimento stravagante che mette in evidenza i fantastici contenuti della community. Inoltre, è stato un ottimo modo per testare il polyfill e fornire feedback al team di ingegneri per contribuire a migliorarlo.

Chrometober 2022 è terminato.

Ci auguriamo che sia stato di tuo gradimento. Qual è la tua funzionalità preferita? Scrivimi su X e facci sapere.

Jhey con in mano un foglio di adesivi dei personaggi di Chrometober.

Potresti anche ricevere degli adesivi da un membro del team se ci incontri a un evento.