Compilando Chrometober

Cómo se creó el libro desplazable para compartir sugerencias y trucos divertidos y aterradores este Chrometober.

Después de Designcember, quisimos crear Chrometober este año como una forma de destacar y compartir contenido web de la comunidad y el equipo de Chrome. Designcember mostró el uso de Container Queries, pero este año presentaremos la API de animaciones con desplazamiento de CSS.

Descubre la experiencia de desplazamiento de libros en web.dev/chrometober-2022.

Descripción general

El objetivo del proyecto era ofrecer una experiencia fantástica que destaque la API de animaciones vinculadas con el desplazamiento. Pero, aunque era alucinante, la experiencia debía ser responsiva y accesible también. El proyecto también ha sido una excelente manera de probar el polyfill de API que está en desarrollo activo, además de probar diferentes técnicas y herramientas en combinación. ¡Y todo con un tema festivo de Halloween!

La estructura de nuestro equipo se veía de la siguiente manera:

Cómo crear un borrador de una experiencia de narración a través del desplazamiento

Las ideas para Chrometober comenzaron a surgir en nuestro primer evento fuera de las instalaciones del equipo en mayo de 2022. Una colección de garabatos nos hizo pensar en formas en las que un usuario podría desplazarse por algún tipo de guion gráfico. Inspirados en los videojuegos, consideramos una experiencia de desplazamiento a través de escenas como cementerios y una casa embrujada.

Un cuaderno está sobre un escritorio con varios garabatos y garabatos relacionados con el proyecto.

Fue emocionante tener la libertad creativa para llevar mi primer proyecto de Google en una dirección inesperada. Este fue un prototipo inicial de cómo un usuario podría navegar por el contenido.

A medida que el usuario se desplaza lateralmente, los bloques rotan y se acercan. Sin embargo, decidí alejarme de esta idea por la preocupación de cómo podríamos hacer que esta experiencia fuera excelente para los usuarios en dispositivos de todos los tamaños. En cambio, me incliné por el diseño de algo que había hecho en el pasado. En 2020, tuve la suerte de tener acceso a GreenSock's ScrollTrigger para compilar demostraciones de lanzamientos.

Una de las demostraciones que creé fue un libro en 3D con CSS en el que las páginas se giraban a medida que te desplazabas, y esto parecía mucho más adecuado para lo que queríamos en Chrometober. La API de animaciones vinculadas con desplazamiento es un reemplazo perfecto para esa funcionalidad. También funciona bien con scroll-snap, como verás.

El ilustrador del proyecto, Tyler Reed, fue muy bueno para alterar el diseño a medida que cambiaba las ideas. Tyler hizo un gran trabajo al tomar todas las ideas creativas que se le presentaron y hacerlas realidad. Fue muy divertido intercambiar ideas. Una gran parte de cómo queríamos que esto funcionara era tener componentes divididos en bloques aislados. De esa manera, podríamos componerlos en escenas y, luego, elegir lo que creamos.

Una de las escenas de la composición con una serpiente, un ataúd con brazos que salen, un zorro con una varita en un caldero, un árbol con una cara espeluznante y una gárgola que sostiene una linterna de calabaza.

La idea principal era que, a medida que el usuario avanzaba en el libro, podía acceder a bloques de contenido. También podían interactuar con toques de extravagancia, incluidos los huevos de Pascua que incorporamos a la experiencia, por ejemplo, un retrato en una casa embrujada cuyos ojos seguían el puntero o animaciones sutiles activadas por consultas de contenido multimedia. Estas ideas y funciones se animarían al desplazarse. Una de las primeras ideas fue un conejo zombi que se elevaba y se traducía a lo largo del eje x cuando el usuario se desplazaba.

Familiarízate con la API

Antes de empezar a jugar con funciones individuales y huevos de Pascua, necesitábamos un libro. Por lo tanto, decidimos convertir esto en una oportunidad para probar el conjunto de funciones de la API emergente de animaciones vinculadas al desplazamiento de CSS. Por el momento, la API de animaciones vinculadas al desplazamiento no es compatible con ningún navegador. Sin embargo, mientras desarrollaban la API, los ingenieros del equipo de interacciones trabajaron en un polyfill. Esto proporciona una manera de probar la forma de la API a medida que se desarrolla. Eso significa que podríamos usar esta API hoy mismo, y los proyectos divertidos como este suelen ser un excelente lugar para probar funciones experimentales y enviar comentarios. Descubre qué aprendimos y los comentarios que pudimos proporcionar más adelante en el artículo.

En un nivel alto, puedes usar esta API para vincular animaciones para desplazarte. Es importante tener en cuenta que no puedes activar una animación durante el desplazamiento. Esto es algo que podrías hacer más adelante. Las animaciones vinculadas al desplazamiento también se dividen en dos categorías principales:

  1. Son aquellas que reaccionan a la posición de desplazamiento.
  2. Son aquellas que reaccionan a la posición de un elemento en su contenedor de desplazamiento.

Para crear el último, usamos un ViewTimeline aplicado a través de una propiedad animation-timeline.

Este es un ejemplo de cómo se ve el uso de ViewTimeline en CSS:

.element-moving-in-viewport {
  view-timeline-name: foo;
  view-timeline-axis: block;
}

.element-scroll-linked {
  animation: rotate both linear;
  animation-timeline: foo;
  animation-delay: enter 0%;
  animation-end-delay: cover 50%;
}

@keyframes rotate {
 to {
   rotate: 360deg;
 }
}

Creamos una ViewTimeline con view-timeline-name y definimos el eje para ella. En este ejemplo, block hace referencia a un block lógico. La animación se vincula al desplazamiento con la propiedad animation-timeline. animation-delay y animation-end-delay (al momento de la redacción) son las formas en que definimos las fases.

Estas fases definen los puntos en los que la animación debe vincularse en relación con la posición de un elemento en su contenedor de desplazamiento. En nuestro ejemplo, indicamos que se debe iniciar la animación cuando el elemento ingresa (enter 0%) al contenedor de desplazamiento. Y termina cuando cubre el 50% (cover 50%) del contenedor de desplazamiento.

Esta es nuestra demostración en acción:

También puedes vincular una animación al elemento que se mueve en la ventana de visualización. Para ello, configura animation-timeline como el view-timeline del elemento. Esto es bueno para situaciones como las animaciones de listas. El comportamiento es similar a la forma en que puedes animar elementos al ingresar con 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;
  }
}

Con esto, "Mover" escala verticalmente a medida que ingresa al viewport, lo que activa la rotación de "ícono giratorio".

Lo que descubrí durante la experimentación fue que la API funciona muy bien con scroll-snap. La función de desplazamiento con ajuste combinado con ViewTimeline sería ideal para ajustar los giros de página en un libro.

Prototipar la mecánica

Después de experimentar un poco, pude hacer que funcionara un prototipo de libro. Para pasar las páginas del libro, desplázate horizontalmente.

En la demostración, puedes ver los diferentes activadores destacados con bordes discontinuos.

El código de marcado se ve de la siguiente manera:

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

A medida que te desplazas, las páginas del libro giran, pero se abren o cierran. Esto depende de la alineación del ajuste de desplazamiento de los activadores.

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

Esta vez, no conectamos ViewTimeline en CSS, pero usamos la API de Web Animations en JavaScript. Esto tiene el beneficio adicional de poder iterar sobre un conjunto de elementos y generar el ViewTimeline que necesitamos, en lugar de crearlos cada uno de forma manual.

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

Para cada activador, generamos un ViewTimeline. Luego, animamos la página asociada del activador con ese ViewTimeline. Eso vincula la animación de la página para desplazarse. Para nuestra animación, rotaremos un elemento de la página en el eje Y para hacerla girar. También traducimos la página en el eje Z para que se comporte como un libro.

Revisión general

Una vez que haya descubierto el mecanismo del libro, pude concentrarme en dar vida a las ilustraciones de Tyler.

Astrofotografía

El equipo usó Astro para Designcember en 2021 y me encantó volver a usarlo para Chrometober. La experiencia del desarrollador de poder dividir las tareas en componentes es adecuada para este proyecto.

El libro en sí es un componente. También es una colección de componentes de página. Cada página tiene dos lados y fondos. Los elementos secundarios de un lado de la página son componentes que se pueden agregar, quitar y posicionar con facilidad.

Cómo crear un libro

Para mí, era importante que los bloques fueran fáciles de administrar. También quería facilitar que el resto del equipo pudiera hacer contribuciones.

Las páginas de alto nivel se definen mediante un array de configuración. Cada objeto de página del array define el contenido, el fondo y otros metadatos de una página.

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

Estos se pasan al componente Book.

<Book pages={pages} />

El componente Book es donde se aplica el mecanismo de desplazamiento y se crean las páginas del libro. Se usa el mismo mecanismo del prototipo, pero compartimos varias instancias de ViewTimeline que se crean de forma global.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

De esta manera, podemos compartir los cronogramas para que se usen en otro lugar en lugar de volver a crearlos. Explicaré eso después.

Composición de la página

Cada página es un elemento de lista dentro de una lista:

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

Y la configuración definida se pasa a cada instancia de Page. Las páginas usan la función de espacio de Astro para insertar contenido en cada una.

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

Este código se usa principalmente para configurar la estructura. Los colaboradores pueden trabajar en el contenido del libro en su mayor parte sin tener que tocar este código.

Fondos

El cambio creativo hacia un libro facilitó mucho la división de las secciones, y cada página del libro es una escena tomada del diseño original.

Ilustración de página doble del libro que muestra un manzano en un cementerio. El cementerio tiene varias lápidas y hay un murciélago en el cielo frente a una luna grande.

Como decidimos una relación de aspecto para el libro, el fondo de cada página podría tener un elemento de imagen. Configurar ese elemento en un 200% de ancho y usar object-position en función del lado de la página funciona.

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

Contenido de la página

Veamos cómo crear una de las páginas. En la página tres, aparece un búho que aparece en un árbol.

Se completa con un componente PageThree, como se define en la configuración. Es un componente Astro (PageThree.astro). Estos componentes parecen archivos HTML, pero tienen una valla de código en la parte superior, similar a frontmatter. Esto nos permite realizar acciones como importar otros componentes. El componente de la página tres se ve así:

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

Una vez más, las páginas son atómicas. Se compilan a partir de una colección de componentes. La página tres muestra un bloque de contenido y el búho interactivo, por lo que hay un componente para cada uno.

Los bloques de contenido son los vínculos al contenido que se ve en el libro. Estos también se basan en un objeto de configuración.

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

Esta configuración se importa cuando se requieren bloqueos de contenido. Luego, la configuración de bloque relevante se pasa al componente ContentBlock.

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

También hay un ejemplo de cómo usamos el componente de la página como lugar para posicionar el contenido. Aquí, se posiciona un bloque de contenido.

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

Sin embargo, los estilos generales de un bloque de contenido se encuentran junto con el código 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%;
}

En cuanto a nuestro búho, es una función interactiva, una de las muchas de este proyecto. Este es un pequeño ejemplo que muestra cómo usamos el ViewTimeline compartido que creamos.

En un nivel alto, el componente owl importa algunas imágenes SVG y las alinea con el fragmento de Astro.

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

Y los estilos para posicionar nuestro búho se encuentran junto con el código del componente.

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

Hay un elemento de diseño adicional que define el comportamiento de transform para el búho.

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

El uso de transform-box afecta a transform-origin. Lo hace relativo al cuadro de límite del objeto dentro del SVG. El búho se escala desde el centro inferior, de ahí el uso de transform-origin: 50% 100%.

La parte divertida es cuando vinculamos el búho a uno de nuestros ViewTimeline generados:

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

En este bloque de código, hacemos dos cosas:

  1. Verifica las preferencias de movimiento del usuario.
  2. Si no tiene ninguna preferencia, vincula una animación del búho para desplazarte.

Para la segunda parte, el búho anima en el eje y usando la API de Web Animations. Se usa la propiedad transform individual translate, que está vinculada a un ViewTimeline. Está vinculado a CHROMETOBER_TIMELINES[1] a través de la propiedad timeline. Este es un ViewTimeline que se genera para los giros de página. Esto vincula la animación del búho al giro de página con la fase enter. Define que, cuando la página esté girada en un 80%, se debe comenzar a mover la lechuza. Cuando llegue al 90%, el búho debería terminar su traducción.

Funciones del libro

Ahora ya conoces el enfoque para crear una página y cómo funciona la arquitectura del proyecto. Verás cómo permite que los colaboradores participen y trabajen en una página o función que elijan. Varias características del libro tienen sus animaciones vinculadas al giro de las páginas, por ejemplo, el murciélago que entra y sale cuando se pasa la página.

También tiene elementos potenciados por animaciones de CSS.

Una vez que los bloques de contenido estaban en el libro, hubo tiempo de poner a prueba tu creatividad con otras funciones. Esto brindó la oportunidad de generar interacciones diferentes y probar diferentes formas de implementarlas.

Mantén la capacidad de respuesta

Las unidades de vista del puerto adaptables ajustan el tamaño del libro y sus funciones. Sin embargo, mantener la capacidad de respuesta de las fuentes fue un desafío interesante. Las unidades de consulta de contenedores son una buena opción aquí. Sin embargo, aún no se admiten en todas partes. El tamaño del libro está establecido, por lo que no necesitamos una consulta de contenedor. Se puede generar una unidad de consulta de contenedor intercalada con calc() de CSS y usarse para el tamaño de la fuente.


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

Las calabazas brillan por la noche

Los más observadores pueden haber notado el uso de elementos <source> cuando hablamos de los fondos de página antes. Una deseaba tener una interacción que reaccionara a la preferencia de esquema de colores. Como resultado, los fondos admiten los modos claro y oscuro con diferentes variantes. Dado que puedes usar consultas de medios con el elemento <picture>, es una excelente manera de proporcionar dos estilos de fondo. El elemento <source> consulta la preferencia de esquema de colores y muestra el fondo adecuado.

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

Puedes realizar otros cambios en función de esa preferencia de esquema de colores. Las calabazas de la página dos reaccionan a la preferencia de esquema de colores de un usuario. El SVG que se usa tiene círculos que representan llamas, que se agrandan y se animan en modo oscuro.

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

¿Te está mirando este retrato?

Si revisas la página 10, es posible que notes algo. Te están mirando Los ojos del retrato seguirán tu puntero a medida que te muevas por la página. El truco aquí es asignar la ubicación del puntero a un valor de traducción y pasarlo a través de 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)
 }

Este código toma rangos de entrada y salida, y asigna los valores dados. Por ejemplo, este uso daría el valor 625.

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

Para el retrato, el valor de entrada es el punto central de cada ojo, más o menos una distancia en píxeles. El rango de salida es la cantidad que los ojos pueden traducir en píxeles. Y, luego, la posición del puntero en el eje x o y se pasa como valor. Para obtener el punto central de los ojos mientras los mueves, estos ojos están duplicados. Los originales no se mueven, son transparentes y se usan como referencia.

Se trata de unirlos y actualizar los valores de las propiedades personalizadas de CSS de los ojos para que estos puedan moverse. Una función está vinculada al evento pointermove en función de window. A medida que se activa, se usan los límites de cada ojo para calcular los puntos centrales. Luego, la posición del puntero se asigna a valores que se establecen como valores de propiedad personalizada de los ojos.

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 vez que los valores se pasan a CSS, los estilos pueden hacer lo que quieran con ellos. Lo mejor aquí es usar CSS clamp() para que el comportamiento sea diferente para cada ojo, de modo que puedas hacer que cada ojo se comporte de manera diferente sin volver a tocar el código 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);
 }

Cómo lanzar hechizos

Si miras la página seis, ¿te sientes fascinado? En esta página, se muestra el diseño de nuestro fantástico zorro mágico. Si mueves el puntero, es posible que veas un efecto de rastro del cursor personalizado. Esta función usa la animación de lienzo. Un elemento <canvas> se ubica por encima del resto del contenido de la página con pointer-events: none. Esto significa que los usuarios aún pueden hacer clic en los bloques de contenido que aparecen debajo.

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

Al igual que nuestro retrato escucha un evento pointermove en window, nuestro elemento <canvas> también lo hace. Sin embargo, cada vez que se activa el evento, creamos un objeto para animar en el elemento <canvas>. Estos objetos representan formas utilizadas en el recorrido del cursor. Tienen coordenadas y un tono aleatorio.

Se vuelve a usar nuestra función mapRange anterior, ya que podemos usarla para asignar el delta del puntero a size y rate. Los objetos se almacenan en un array que se repite cuando los objetos se dibujan en el elemento <canvas>. Las propiedades de cada objeto le indican a nuestro elemento <canvas> dónde se deben dibujar.

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)

Para dibujar en el lienzo, se crea un bucle con requestAnimationFrame. El rastro del cursor solo debe renderizarse cuando la página está en la vista. Tenemos un elemento IntersectionObserver que se actualiza y determina qué páginas están a la vista. Si una página está en vista, los objetos se renderizan como círculos en el lienzo.

Luego, recorremos el array blocks y dibujamos cada parte de la ruta. Cada fotograma reduce el tamaño y altera la posición del objeto en función de rate. Esto produce ese efecto de caída y escalamiento. Si el objeto se reduce por completo, se quita del 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)
 }

Si la página desaparece de la vista, se quitan los objetos de escucha de eventos y se cancela el bucle de fotogramas de animación. También se borra el array blocks.

Aquí tienes el rastro del cursor en acción.

Revisión de accesibilidad

Está bien crear una experiencia divertida para explorar, pero no es bueno si los usuarios no pueden acceder a ella. La experiencia de Adam en este área fue invaluable para preparar Chrometober para una revisión de accesibilidad antes del lanzamiento.

Estas son algunas de las áreas destacadas que se abordan:

  • Asegúrate de que el HTML que usaste sea semántico. Esto incluía elementos de punto de referencia adecuados, como <main> para el libro, el uso del elemento <article> para cada bloque de contenido y los elementos <abbr> en los que se introducen acrónimos. Pensar en el futuro mientras se creaba el libro hizo que todo fuera más accesible. El uso de encabezados y vínculos facilita la navegación del usuario. El uso de una lista para las páginas también significa que la tecnología de accesibilidad anuncia la cantidad de páginas.
  • Asegúrate de que todas las imágenes usen los atributos alt adecuados. En el caso de los SVG intercalados, el elemento title está presente cuando es necesario.
  • Usar atributos aria cuando mejoren la experiencia El uso de aria-label para las páginas y sus lados le comunica al usuario en qué página se encuentra. El uso de aria-describedBy en los vínculos "Más información" comunica el texto del bloque de contenido. De esta manera, se elimina la ambigüedad sobre adónde llevará el vínculo al usuario.
  • En cuanto a los bloqueos de contenido, está disponible la opción de hacer clic en toda la tarjeta y no solo en el vínculo "Leer más".
  • Anteriormente, se mencionó el uso de un IntersectionObserver para hacer un seguimiento de las páginas que están en vista. Esto tiene muchos beneficios que no solo se relacionan con el rendimiento. Las páginas que no estén en la vista tendrán pausadas las animaciones o interacciones. Sin embargo, estas páginas también tienen aplicado el atributo inert. Esto significa que los usuarios que usan un lector de pantalla pueden explorar el mismo contenido que los usuarios videntes. El enfoque permanece dentro de la página que está en la vista y los usuarios no pueden cambiar a otra página.
  • Por último, pero no menos importante, usamos consultas de medios para respetar la preferencia de un usuario por el movimiento.

Esta es una captura de pantalla de la opinión en la que se destacan algunas de las medidas implementadas.

se identifica como alrededor de todo el libro, lo que indica que debe ser el punto de referencia principal que encuentren los usuarios de tecnología de asistencia. Hay más información en la captura de pantalla." width="800" height="465">

Captura de pantalla del libro de Chrometober abierto. Se proporcionan cuadros verdes sobre varios aspectos de la interfaz de usuario, que describen la funcionalidad de accesibilidad prevista y los resultados de la experiencia del usuario que ofrecerá la página. Por ejemplo, las imágenes tienen texto alternativo. Otro ejemplo es una etiqueta de accesibilidad que declara que las páginas fuera de la vista son inertes. En la captura de pantalla, se describe más información.

Qué aprendimos

El objetivo de Chrometober no solo era destacar el contenido web de la comunidad, sino también probar el polyfill de la API de animaciones vinculadas al desplazamiento que está en desarrollo.

Reservamos una sesión durante nuestra cumbre de equipos en Nueva York para probar el proyecto y abordar los problemas que surgieron. La contribución del equipo fue invaluable. También fue una gran oportunidad para enumerar todo lo que debíamos abordar antes de que pudiéramos lanzarlo.

El equipo de CSS, IU y DevTools se sienta alrededor de una mesa en una sala de conferencias. Una está de pie frente a una pizarra cubierta de notas adhesivas. Otros miembros del equipo están sentados alrededor de la mesa con refrigerios y laptops.

Por ejemplo, probar el libro en dispositivos generó un problema de renderización. Nuestro libro no se procesaría como se esperaba en los dispositivos iOS. Las unidades de viewport afectan el tamaño de la página, pero cuando hay un recorte, el libro se ve afectado. La solución fue usar viewport-fit=cover en el viewport meta:

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

Esta sesión también planteó algunos problemas con el polyfill de la API. Bramus planteó estos problemas en el repositorio de polyfill. Luego, encontró soluciones para esos problemas y los fusionó en el polyfill. Por ejemplo, esta solicitud de extracción mejoró el rendimiento cuando agregó almacenamiento en caché a parte del polyfill.

Captura de pantalla de una demostración abierta en Chrome. Las herramientas para desarrolladores están abiertas y muestran una medición del rendimiento de referencia.

Captura de pantalla de una demostración abierta en Chrome. Las herramientas para desarrolladores están abiertas y muestran una medición de rendimiento mejorada.

Eso es todo.

Fue un proyecto muy divertido en el que trabajar, lo que generó una experiencia de desplazamiento fantástica que destaca el contenido increíble de la comunidad. Además, fue excelente para probar el polyfill y proporcionar comentarios al equipo de ingeniería para mejorarlo.

Chrometober 2022 llegó a su fin.

¡Esperamos que lo hayas disfrutado! ¿Cuál es tu función favorita? Envíanos un tweet y comunícate con nosotros.

Jhey sostiene una hoja de calcomanías de los personajes de Chrometober.

Incluso es posible que puedas obtener algunas calcomanías de uno de los miembros del equipo si nos ves en un evento.

Foto hero de David Menidrey en Unsplash