Creo Chrometober!

Come il libro a scorrimento è nato 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 scorrimento dei libri 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:

Realizzare un'esperienza di scorrimento

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. Ispirati ai videogiochi, abbiamo considerato un'esperienza di scorrimento di scene come cimiteri e una casa stregata.

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 di dimensioni. 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 avevo creato era un libro CSS 3D in cui le pagine giravano man mano che scorrevi la pagina, che sembrava molto più appropriato per ciò che volevamo per Chrometober. L'API AnimationsLinkedToScroll è un'ottima alternativa a 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, mano a mano che l'utente esplorava il libro, poteva 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à verrebbero animate tramite 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 i singoli elementi e gli esager egg, avevamo bisogno di 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 delle animazioni con link a scorrimento non è attualmente supportata in nessun browser. Tuttavia, durante lo sviluppo dell'API, i tecnici del team dedicato alle interazioni hanno lavorato su un polyfill. In questo modo, puoi 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 posto per provare funzionalità sperimentali e fornire feedback. Scopri cosa abbiamo imparato e il feedback che abbiamo potuto fornire più avanti nell'articolo.

A grandi linee, 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. Quelle che reagiscono alla posizione di un elemento nel 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, indichiamo 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 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. È ideale per scenari come le animazioni dell'elenco. Il comportamento è simile a come potresti animare gli elementi al momento dell'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" fa lo scale up man mano che 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 in combinazione con ViewTimeline sarebbe un'ottima soluzione per agganciare le 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 improvviso. Questo dipende dall'allineamento dello scorrimento-snap 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 il loop su un insieme di elementi e generare l'ViewTimeline di cui abbiamo bisogno, invece di crearli ciascuno 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. 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.

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 a un livello generale 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 consente di applicare il meccanismo di scorrimento e di creare 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 riparleremo più avanti.

Composizione della pagina

Ogni pagina è un elemento 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, per la maggior parte, lavorare ai contenuti del libro senza dover modificare il 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. Ciò ci consente di eseguire operazioni come l'importazione di 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 creati a partire 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 quindi 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. In questo caso, 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 e utile esempio da seguire che mostra come abbiamo utilizzato la nuova metrica ViewTimeline 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%;
}

È presente 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 rispetto al riquadro di delimitazione dell'oggetto all'interno del file SVG. Il gufo aumenta di dimensioni dal centro verso il basso, da qui l'uso di transform-origin: 50% 100%.

La parte divertente è collegare il gufo a uno dei ViewTimeline che abbiamo generato:

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, svolgiamo due operazioni:

  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 spostare il gufo. Al 90%, il gufo dovrebbe terminare 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, c'era il tempo di dare libero sfogo alla creatività con altre funzionalità. Ciò ha offerto l'opportunità di generare interazioni diverse e di provare modi diversi per implementarli.

Mantenere la reattività

Le unità area visibile adattabili ridimensionano il libro e le sue funzionalità. Tuttavia, mantenere i caratteri adattabili è stata una sfida interessante. Le unità di query dei contenitori sono una buona soluzione in questo caso. Tuttavia, non sono ancora supportate ovunque. Le dimensioni del libro sono impostate, quindi non c'è bisogno di una query relativa al contenitore. Un'unità di query del 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);
}

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 query supporti con l'elemento <picture>, è un ottimo modo per aggiungere due stili di sfondo. L'elemento <source> esegue query per determinare la preferenza relativa alla 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?

Visitando la 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 accetta intervalli di input e di output e mappa i valori specificati. Ad esempio, questo utilizzo darebbe al 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. Quindi la posizione del puntatore 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. Durante l'attivazione, i margini 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 che i valori vengono passati al CSS, gli stili possono utilizzarli come preferiscono. La parte migliore è l'utilizzo del CSS clamp() per rendere diverso il comportamento di 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);
 }

Lanciare incantesimi

Se dai un'occhiata alla pagina 6, ti senti incantato? Questa pagina mostra il design della nostra fantastica volpe magica. Se sposti il puntatore, potresti visualizzare un effetto scia 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 il giorno window, così come 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 archiviati in un array che viene riprodotto in loop quando vengono attirati sull'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 è visualizzata una pagina, 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 quell'effetto di caduta e scala. 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 è stato cancellato.

Ecco il percorso del cursore in azione.

Revisione dell'accessibilità

Creare un'esperienza divertente da esplorare è un vantaggio, ma non è positivo se non è accessibile agli utenti. Le competenze di Adam in questo ambito si sono rivelate preziose per preparare Chrometober a una revisione dell'accessibilità prima del rilascio.

Alcune delle aree più importanti 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'uso 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 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 quali pagine sono visualizzate è stato usato in precedenza. Questo approccio offre molti vantaggi che non riguardano solo il 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 intorno 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. Nello screenshot sono indicate altre informazioni.

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 su 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 era 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 richiesta di pull 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.

Il lavoro su questo progetto è stato molto divertente e si è tradotto in un'esperienza di scorrimento stravagante che mette in evidenza i contenuti straordinari 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? Inviami un tweet per farcelo sapere.

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

Potresti persino prendere alcuni adesivi da uno dei membri del team se ci trovi a un evento.

Foto hero di David Menidrey su Unsplash