Creo Chrometober!

Come è nato il libro scorrevole per condividere suggerimenti e trucchi divertenti e spaventosi in questo Chrometober.

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

Dai un'occhiata all'esperienza di lettura dei libri in scorrimento all'indirizzo web.dev/chrometober-2022.

Panoramica

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

La struttura del nostro team era la seguente:

Creare la bozza di un'esperienza di scrollytelling

Le idee per Chrometober hanno iniziato a prendere forma durante il nostro primo team offsite a maggio 2022. Una raccolta di scarabocchi ci ha fatto pensare a come un utente potesse scorrere una sorta di storyboard. Ispirato dai videogiochi, abbiamo preso in considerazione un'esperienza di scorrimento attraverso scene come cimiteri e una casa infestata.

Un taccuino è appoggiato su una scrivania con vari scarabocchi e scritte relative al progetto.

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

Quando l'utente scorre lateralmente, i blocchi ruotano e si riducono. Tuttavia, ho deciso di abbandonare questa idea perché non sapevo come rendere questa esperienza ottimale per gli utenti su dispositivi di tutte le dimensioni. Ho preferito invece optare per il design di qualcosa 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 ho creato era un libro in CSS 3D in cui le pagine si giravano mentre scorrevi, e mi sembrava molto più appropriato per ciò che volevamo per Chrometober. L'API Animations linked to scroll è un'alternativa perfetta per questa funzionalità. Funziona bene anche con scroll-snap, come vedrai.

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

Una delle scene di composizione con un serpente, una bara con delle braccia che fuoriescono, una volpe con una bacchetta magica accanto a un calderone, un albero con una faccia spaventosa e un gargoyle che tiene una lanterna di zucca.

L'idea principale era che, man mano che l'utente leggeva il libro, potesse accedere a blocchi di contenuti. Potevano anche interagire con elementi fantasiosi, inclusi gli easter egg che avevamo integrato nell'esperienza, ad esempio un ritratto in una casa infestata i cui occhi seguivano il cursore o animazioni sottili attivate dalle query sui media. Queste idee e funzionalità verranno animate durante lo scorrimento. Un'idea iniziale era un coniglio zombie che si alzava e si spostava lungo l'asse x quando l'utente scorreva.

Familiarizzare con l'API

Prima di poter iniziare a giocare con le singole funzionalità e gli easter egg, ci serviva un libro. Così abbiamo deciso di trasformare questa opportunità in un test del set di funzionalità per l'API CSS per le animazioni collegate allo scorrimento emergente. L'API AnimationsLinkedToScroll non è attualmente supportata in nessun browser. Tuttavia, durante lo sviluppo dell'API, i tecnici del team di interazioni hanno lavorato a un polyfill. In questo modo, puoi testare la forma dell'API man mano che si sviluppa. Ciò significa che potremmo utilizzare questa API oggi stesso e progetti divertenti come questo sono spesso un ottimo posto per provare funzionalità sperimentali e fornire feedback. Scopri cosa abbiamo imparato e il feedback che abbiamo potuto fornire più avanti nell'articolo.

A livello generale, puoi utilizzare questa API per collegare le animazioni allo scorrimento. È importante notare che non puoi attivare un'animazione durante lo 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 contenitore a 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 a block logico. 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 le fasi che definiamo.

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

Ecco la nostra demo in azione:

Puoi anche collegare un'animazione all'elemento che si muove nell'area visibile. Puoi farlo impostando animation-timeline come view-timeline dell'elemento. Questa opzione è utile per scenari come le animazioni di elenchi. Il comportamento è simile a come potresti animare gli elementi all'inserimento 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".

Dagli esperimenti è emerso che l'API funziona molto bene con scroll-snap. La funzionalità di scorrimento automatico combinata con ViewTimeline è ideale per scattare foto di pagine di un libro.

Prototipazione della meccanica

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

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

Il markup ha il seguente aspetto:

<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 si girano, ma si aprono o si chiudono in modo brusco. Questo dipende dall'allineamento di snap allo scorrimento degli attivatori.

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 il vantaggio aggiuntivo di poter eseguire un ciclo su un insieme di elementi e generare i ViewTimeline di cui abbiamo bisogno, anziché crearli manualmente.

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. Questo collega l'animazione della pagina allo scorrimento. Per la nostra animazione, ruotiamo un elemento della pagina sull'asse Y per girare la pagina. Trattiamo anche la pagina stessa sull'asse z in modo che si comporti come un libro.

Riassumendo

Una volta elaborato il meccanismo del libro, potevo concentrarmi sulla realizzazione delle illustrazioni di Tyler.

Astrofotografia

Il team ha utilizzato Astro per Designcember nel 2021 e volevo usarlo di nuovo per Chrometober. L'esperienza dello sviluppatore di poter suddividere le cose in componenti è molto adatta a questo progetto.

Il libro stesso è un componente. È anche una raccolta di componenti di pagina. Ogni pagina ha due lati e sfondi. I componenti secondari di un lato della pagina sono componenti che possono essere aggiunti, rimossi e posizionati facilmente.

Creare un fotolibro

Per me era importante rendere i blocchi facili da gestire. Volevo anche semplificare la possibilità per il resto del team di dare il proprio contributo.

Le pagine a un livello elevato 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 sequenze temporali per utilizzarle altrove anziché ricrearle. Ne parleremo più avanti.

Composizione della pagina

Ogni pagina è una voce di un elenco all'interno 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à di 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 ai contenuti del libro per la maggior parte senza dover toccare questo codice.

Sfondi

Il passaggio della creatività a un libro ha semplificato notevolmente la suddivisione delle sezioni e ogni apertura del libro è una scena tratta dal design originale.

Illustrazione a doppia pagina del libro che mostra un melo in un cimitero. Il cimitero ha più lapidi e c&#39;è 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 terza pagina mostra un gufo che appare su un albero.

Viene compilato con un componente PageThree, come definito nella configurazione. Si tratta di un componente Astro (PageThree.astro). Questi componenti sembrano file HTML, ma hanno una barra di codice in alto simile al frontmatter. In questo modo possiamo, ad esempio, importare altri componenti. Il componente per la terza pagina è il seguente:

---
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 funzionalità. La terza pagina contiene un blocco di contenuti e la civetta interattiva, quindi è presente un componente per ciascuno.

I blocchi di contenuti sono i link ai contenuti visualizzati all'interno del 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 blocchi di contenuti. La configurazione del blocco pertinente viene poi passata al componente ContentBlock.

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

Qui è riportato anche un esempio di come utilizziamo il componente della pagina come punto di posizionamento dei 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 si trovano nello stesso spazio del 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%;
}

La civetta è una funzionalità interattiva, una delle tante di questo progetto. Questo è un piccolo esempio che mostra come abbiamo utilizzato la visualizzazione della cronologia condivisa che abbiamo creato.

A grandi linee, il nostro componente gufo importa alcuni SVG e li inserisce in linea utilizzando il frammento di Astro.

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

Gli stili per il posizionamento della civetta si trovano nello stesso spazio del codice del componente.

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

Esiste un elemento di stile aggiuntivo che definisce il comportamento transform per la civetta.

.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 di SVG. Il gufo aumenta di dimensioni dal centro verso il basso, da qui l'uso di transform-origin: 50% 100%.

La parte divertente è quando colleghiamo la civetta 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 ha preferenze, collega un'animazione della civetta allo scorrimento.

Per la seconda parte, la civetta si anima sull'asse Y utilizzando l'API Web Animations. Viene utilizzata la proprietà di trasformazione individuale translate, collegata a un ViewTimeline. È collegata a CHROMETOBER_TIMELINES[1] tramite la proprietà timeline. Si tratta di un ViewTimeline generato per i passaggi di pagina. Questo collega l'animazione della civetta al passaggio di pagina utilizzando la fase enter. Definisce che, quando la pagina è girata all'80%, inizia a muovere la civetta. Al 90%, la civetta dovrebbe completare la traduzione.

Funzionalità dei libri

Ora hai visto l'approccio per creare 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à a loro scelta. Le animazioni di varie funzionalità del libro sono collegate al passaggio da una pagina all'altra, ad esempio il pipistrello che entra ed esce quando si girano le pagine.

Contiene anche elementi basati su animazioni CSS.

Una volta inseriti i blocchi di contenuti nel libro, è stato possibile dare sfogo alla creatività con altre funzionalità. Ciò ha fornito l'opportunità di generare interazioni diverse e provare diversi modi di implementare le cose.

Mantenere la reattività

Le unità dell'area visibile adattabile definiscono le dimensioni del libro e delle sue funzionalità. Tuttavia, mantenere i caratteri adattabili è stata una sfida interessante. Le unità di query dei contenitori sono adatte a questo caso. Tuttavia, non sono ancora supportate ovunque. Le dimensioni del libro sono impostate, quindi non è necessaria una query del contenitore. Un'unità di query del contenitore in linea può essere generata con CSS calc() e utilizzata per le dimensioni 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);
}

Le zucche brillano di notte

I più attenti avranno notato l'uso degli elementi <source> quando abbiamo parlato degli sfondi delle pagine in precedenza. Una voleva un'interazione che reagisse alla preferenza per la combinazione di colori. Di conseguenza, gli sfondi supportano le modalità Luce e Buio con varianti diverse. Poiché puoi utilizzare le media query con l'elemento <picture>, è un ottimo modo per fornire due stili di sfondo. L'elemento <source> esegue 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 per la combinazione di colori. Le zucche nella seconda pagina reagiscono alla preferenza di combinazione di colori di un utente. L'SVG utilizzato presenta cerchi che rappresentano fiamme, che aumentano di dimensioni e si animano in modalità oscura.

.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 guardando. Gli occhi del ritratto seguiranno il cursore mentre ti sposti nella pagina. Il trucco è mappare la posizione del cursore a un valore di traduzione e trasmetterlo 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 di output e mappa i valori specificati. Ad esempio, questo utilizzo restituirà il valore 625.

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

Per il ritratto, il valore di input è il punto centrale di ciascun occhio, più o meno una distanza in pixel. L'intervallo di output indica la quantità che gli occhi possono tradurre in pixel. La posizione del cursore sull'asse x o y viene passata come valore. Per ottenere il punto centrale degli occhi mentre li muovi, gli occhi vengono duplicati. Gli originali non si muovono, sono trasparenti e vengono utilizzati come riferimento.

A questo punto, devi solo collegare gli elementi e aggiornare i valori delle proprietà CSS personalizzate sugli occhi in modo che possano muoversi. Una funzione è associata all'evento pointermove rispetto a window. Quando viene attivato, i limiti di ciascun occhio vengono utilizzati per calcolare i punti centrali. La posizione del cursore 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 trasmessi i valori al CSS, gli stili possono utilizzarli come preferiscono. La parte migliore è l'utilizzo del CSS clamp() per rendere diverso il comportamento per ciascun occhio, in modo da poter fare in modo che ogni occhio si comporti in modo diverso senza dover modificare di nuovo il codice 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);
 }

Incantesimi

Se dai un'occhiata alla pagina 6, ti senti incantato? Questa pagina mostra il design della nostra fantastica volpe magica. Se muovi il cursore, potresti vedere un effetto di traccia del cursore personalizzato. Viene utilizzata 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 vale per il nostro elemento <canvas>. Tuttavia, ogni volta che viene attivato l'evento, viene creato un oggetto da animare nell'elemento <canvas>. Questi oggetti rappresentano le forme utilizzate nella traccia del cursore. Hanno coordinate e una tonalità casuale.

La funzione mapRange di cui abbiamo parlato in precedenza viene utilizzata di nuovo, poiché possiamo utilizzarla per mappare il delta del cursore a size e rate. Gli oggetti vengono memorizzati in un array su cui viene eseguito un ciclo quando vengono disegnati nell'elemento <canvas>. Le proprietà di ogni oggetto indicano all'elemento <canvas> dove devono essere disegnate le cose.

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 sulla tela, viene creato un loop con requestAnimationFrame. La traccia del cursore deve essere visualizzata solo quando la pagina è visibile. Abbiamo un IntersectionObserver che aggiorna e determina quali pagine sono visibili. Se una pagina è visibile, gli oggetti vengono visualizzati come cerchi sul canvas.

Quindi eseguiamo un ciclo sull'array blocks e disegniamo ogni parte del percorso. Ogni frame riduce le dimensioni e modifica la posizione dell'oggetto di rate. Questo produce l'effetto di caduta e ridimensionamento. 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 è visibile, i listener di eventi vengono rimossi e il loop del frame dell'animazione viene annullato. Anche l'array blocks viene cancellato.

Ecco il percorso del cursore in azione.

Revisione dell'accessibilità

È bello creare un'esperienza divertente da esplorare, ma non serve a nulla se non è accessibile agli utenti. Le competenze di Adam in questo campo si sono rivelate preziose per preparare Chrometober a una revisione dell'accessibilità prima del rilascio.

Ecco alcune delle aree coperte:

  • Assicurati che l'HTML utilizzato sia semantico. Sono inclusi elementi di riferimento appropriati come <main> per il libro, l'uso dell'elemento <article> per ogni blocco di contenuti e gli elementi <abbr> in cui vengono introdotti gli acronimi. Anticipare le esigenze durante la creazione del libro ha reso tutto più accessibile. L'uso di intestazioni e link semplifica la navigazione per l'utente. L'utilizzo di un elenco per le pagine significa anche che il numero di pagine viene annunciato dalla tecnologia per la disabilità.
  • Assicurati che tutte le immagini utilizzino gli attributi alt appropriati. Per gli SVG in linea, l'elemento title è presente dove necessario.
  • Utilizzare gli attributi aria se migliorano l'esperienza. L'uso di aria-label per le pagine e i relativi lati comunica all'utente la pagina in cui si trova. L'uso di aria-describedBy nei link "Continua a leggere" comunica il testo del blocco di contenuti. In questo modo viene eliminata l'ambiguità su dove reindirizzerà l'utente il link.
  • In merito ai blocchi di contenuti, è possibile fare clic sull'intera scheda e non solo sul link "Scopri di più".
  • L'utilizzo di un IntersectionObserver per monitorare le pagine in visualizzazione è stato discusso in precedenza. Questo approccio presenta molti vantaggi non solo in termini di rendimento. Le animazioni o le interazioni delle pagine non in vista verranno messe in pausa. Tuttavia, a queste pagine è stato 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 nella pagina visualizzata e gli utenti non possono passare a un'altra pagina.
  • Infine, utilizziamo le query sui media per rispettare le preferenze di un utente in merito al movimento.

Ecco uno screenshot della revisione che mette in evidenza alcune delle misure in vigore.

elemento è identificato come intorno all'intero libro, a indicare che dovrebbe essere il punto di riferimento principale per gli utenti delle tecnologie per la disabilità. Maggiori dettagli sono riportati nello screenshot." width="800" height="465">

Screenshot del libro di Chrometober aperto. Sono disponibili caselle con bordi verdi attorno a vari aspetti dell&#39;interfaccia utente, che descrivono la funzionalità di accessibilità prevista e i risultati dell&#39;esperienza utente che la pagina offrirà. Ad esempio, le immagini hanno un testo alternativo. Un altro esempio è un&#39;etichetta di accessibilità che dichiara che le pagine non visibili sono inattive. Scopri di più nello screenshot.

Che cosa abbiamo imparato

Lo scopo di Chrometober non era solo mettere in evidenza i contenuti web della community, ma anche testare il polyfill dell'API Animations linked to scroll in fase di sviluppo.

Abbiamo riservato una sessione durante il nostro summit di team a New York per testare il progetto e risolvere i problemi che si sono presentati. Il contributo del team è stato inestimabile. È stata anche un'ottima opportunità per elencare tutti gli aspetti da affrontare prima di poter andare in diretta.

Il team di CSS, UI e DevTools è seduto intorno a un tavolo in una sala conferenze. Una è in piedi davanti a una lavagna ricoperta di post-it. Gli altri membri del team sono seduti intorno al tavolo con bevande e laptop.

Ad esempio, il test del libro sui dispositivi ha sollevato un problema di rendering. Il nostro libro non veniva visualizzato come previsto sui dispositivi iOS. Le unità dell'area visibile definiscono le dimensioni della pagina, ma quando era presente una tacca, il libro era interessato. 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 unite al polyfill. Ad esempio, questa pull request ha migliorato le prestazioni aggiungendo la memorizzazione nella cache a parte del polyfill.

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

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, che ha dato vita a un'esperienza di scorrimento spensierata che mette in evidenza i fantastici contenuti della community. Non solo, è stato fantastico per testare il polyfill e fornire feedback al team tecnico per contribuire a migliorarlo.

Chrometober 2022 è terminato.

Ci auguriamo che sia stato di tuo gradimento. Qual è la tua funzionalità preferita? Fammi un tweet e fammi sapere.

Jhey tiene in mano un foglio di adesivi con i personaggi di Chrometober.

Se ci incontri a un evento, potresti anche ricevere degli adesivi da uno dei membri del team.

Foto di David Menidrey su Unsplash