Compilando Chrometober

Cómo el libro desplazable cobra vida por compartir sugerencias y trucos divertidos y aterradores en este Chrometober.

A partir de Designcember, quisimos crear Chrometober este año para que puedas destacar y compartir contenido web de la comunidad y del equipo de Chrome. Designcember mostró el uso de las consultas de contenedores, pero este año mostraremos la API de animaciones de CSS vinculadas con desplazamiento.

Descubre la experiencia desplazable del libro en web.dev/chrometober-2022.

Descripción general

El objetivo del proyecto era ofrecer una experiencia extravagante que destacara la API de animaciones vinculadas a desplazamientos. Pero, si bien era extravagante, la experiencia debía ser responsiva y accesible también. El proyecto también ha sido una excelente manera de probar el polyfill de la API que se encuentra en desarrollo activo, además de probar diferentes técnicas y herramientas en conjunto. Todo con una temática festiva de Halloween.

La estructura de nuestro equipo se ve así:

Cómo crear una experiencia de narración con desplazamiento

Las ideas para ChromeTober comenzaron a fluir en nuestro primer equipo fuera de la oficina en mayo de 2022. Una colección de garabatos nos hizo pensar en las formas en que un usuario podía desplazarse a lo largo de algún tipo de guión gráfico. Inspiradas en los videojuegos, consideramos una experiencia de desplazamiento a través de escenas como cementerios y una casa embrujada.

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

Fue emocionante tener la libertad creativa de llevar mi primer proyecto de Google hacia 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 ajustan a la escala. Sin embargo, decidí alejarme de esta idea porque me preocupaba cómo podíamos hacer que esta experiencia fuera excelente para los usuarios de dispositivos de todos los tamaños. En cambio, me pasé al 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 versiones.

Una de las demostraciones que había creado fue un libro 3D-CSS en el que las páginas giraban a medida que te desplazabas, y esto era mucho más apropiado para lo que queríamos para Chrometober. La API de animaciones vinculadas a desplazamientos es una alternativa perfecta para esa funcionalidad. Como verás, también funciona bien con scroll-snap.

Nuestro ilustrador del proyecto, Tyler Reed, alteró el diseño a medida que cambiábamos de ideas. Tyler hizo un trabajo fantástico al tomar todas las ideas creativas que se le arrojaban y hacerlas realidad. Fue muy divertido intercambiar ideas juntos. Una gran parte de cómo queríamos que funcionara era tener atributos divididos en bloques aislados. De esa forma, podríamos componerlas en escenas y, luego, elegir qué le dimos vida.

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

La idea principal era que, a medida que el usuario se abriera camino por el libro, podía acceder a bloques de contenido. También podían interactuar con toques de extravagancia, incluidos los huevos de Pascua que habíamos integrado en la experiencia; por ejemplo, un retrato en una casa embrujada cuyos ojos siguen tu puntero o animaciones sutiles activadas por consultas de medios. Estas ideas y características se animarían en el desplazamiento. Una idea inicial fue un conejo zombi que se elevaría 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 para la API emergente de animaciones con desplazamiento de CSS. Por el momento, la API de animaciones vinculadas a desplazamientos no es compatible con ningún navegador. Sin embargo, mientras desarrollaban la API, los ingenieros del equipo de interacciones estuvieron trabajando en un polyfill. Esto proporciona una manera de probar la forma de la API a medida que se desarrolla. Eso significa que podemos usar esta API hoy en día, y los proyectos divertidos como este suelen ser un excelente lugar para probar funciones experimentales y proporcionar comentarios. Descubre lo que aprendimos y los comentarios que podemos entregar más adelante en este artículo.

En términos generales, puedes usar esta API para vincular animaciones que te permitan desplazarse. Es importante tener en cuenta que no puedes activar una animación con el desplazamiento, ya que esto podría ocurrir más adelante. Las animaciones vinculadas con desplazamiento también se dividen en dos categorías principales:

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

Para crear esta última, 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 su eje. En este ejemplo, block hace referencia a un block lógico. La animación se vincula para desplazarse con la propiedad animation-timeline. animation-delay y animation-end-delay (al momento de escribir) son las formas en que definimos las fases.

Estas fases definen los puntos en los que se debe vincular la animación en relación con la posición de un elemento en su contenedor de desplazamiento. En nuestro ejemplo, se indica que debes iniciar la animación cuando el elemento ingrese (enter 0%) en el contenedor de desplazamiento. Finaliza cuando haya cubierto 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 el viewport. Para ello, configura animation-timeline como el view-timeline del elemento. Esto es bueno para situaciones como las animaciones de lista. El comportamiento es similar a cómo podrías animar los elementos en la entrada 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;
  }
}

De esta forma, el botón "Mover" se escala verticalmente a medida que entra en el viewport, lo que activa la rotación de "Spinner".

Durante mis experimentos, encontré que la API funciona muy bien con scroll-Snap. El ajuste con desplazamiento combinado con ViewTimeline sería una excelente opción para ajustar el giro de página de un libro.

Prototipado de la mecánica

Después de algunas pruebas, pude hacer que el prototipo de un libro funcionara. Desplázate de forma horizontal para pasar las páginas del libro.

En la demostración, puedes ver los diferentes activadores que se destacan con bordes discontinuos.

La marca se ve un poco así:

<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 cambian, pero se abren o se 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, sino que usamos la API de Web Animations en JavaScript. Esto tiene el beneficio adicional de poder aplicar un bucle a un conjunto de elementos y generar el ViewTimeline que necesitamos, en lugar de crear 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 pasar la página. También trasladamos la página en el eje z para que se comporte como un libro.

Revisión general

Una vez que desarrollé el mecanismo para crear el libro, pude centrarme en dar vida a las ilustraciones de Tyler.

Astro

El equipo utilizó Astro para Designcember en 2021 y me entusiasmaba volver a usarlo para Chrometober. La experiencia de los desarrolladores de poder dividir elementos 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 del lado de una página son componentes que se pueden agregar, quitar y posicionar con facilidad.

Crear un libro

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

Las páginas en un nivel alto 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 a nivel global.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

De esta forma, podemos compartir las líneas de tiempo que se usarán en otro lugar en lugar de recrearlas. Explicaré eso después.

Composición de la página

Cada página es un elemento 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 ranura 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 estructuras. Los colaboradores pueden trabajar en la mayor parte del contenido del libro sin tener que tocar este código.

Fondos

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

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

Como habíamos decidido por una relación de aspecto para el libro, el fondo de cada página podría tener un elemento de imagen. Puedes configurar ese elemento en un 200% de ancho y usar object-position basado en el lado de la página.

.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. La página tres muestra un búho que aparece en un árbol.

Se propaga 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 la frontmatter. Esto nos permite importar otros componentes. El componente de la página tres se ve de la siguiente manera:

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

De nuevo, las páginas son atómicas por naturaleza. Se crean a partir de un conjunto de funciones. La página tres presenta 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 controlan mediante 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 bloques de contenido. Luego, la configuración de bloque relevante se pasa al componente ContentBlock.

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

También hay un ejemplo aquí 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 ubican junto al 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 al búho, es una función interactiva, una de muchas en este proyecto. Este es un buen ejemplo breve que muestra cómo usamos el ViewTimeline compartido que creamos.

A grandes rasgos, nuestro componente owl importa algunos SVG y los intercala con Astro's Fragment.

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

Y los estilos para posicionar nuestro búho se encuentran en el mismo lugar que el código del componente.

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

Hay un elemento adicional de estilo que define el comportamiento de transform del búho.

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

El uso de transform-box afecta a transform-origin. Se hace relativo al cuadro delimitador del objeto dentro del SVG. El búho se escala hacia arriba desde la parte inferior central, por lo que se utiliza transform-origin: 50% 100%.

La parte divertida es cuando vinculamos el búho con 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. Revisa las preferencias de movimiento del usuario.
  2. Si no tiene preferencia, vincula una animación del búho para desplazarse.

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

Elementos del libro

Ya conoces el enfoque para crear una página y cómo funciona la arquitectura del proyecto. Puedes ver cómo permite a los colaboradores acceder y trabajar en una página o función de su elección. Varias funciones del libro tienen sus animaciones vinculadas al cambio de página del libro; por ejemplo, el murciélago que entra y sale volando al pasar de página.

También cuenta con elementos que funcionan con animaciones de CSS.

Una vez que los bloques de contenido estaban en el libro, había tiempo para ser creativo con otras funciones. Esto proporcionó la oportunidad de generar algunas interacciones diferentes y probar diferentes maneras de implementar las cosas.

Mantener la capacidad de respuesta

Las unidades de viewport responsivas dimensionan el libro y sus funciones. Sin embargo, mantener las fuentes responsivas fue un desafío interesante. Las unidades de consulta de contenedores son una buena opción en este caso. Sin embargo, aún no se admiten en todas partes. El tamaño del libro está configurado, por lo que no necesitamos una consulta de contenedor. Se puede generar una unidad de consulta de contenedor intercalado con CSS calc() 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);
}

Calabazas brillando por la noche

Es posible que aquellos con ojo agudo hayan notado el uso de elementos <source> cuando se discutió anteriormente el uso de los fondos de página. Una quería 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. Debido a 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>

Podrías introducir otros cambios en función de esa preferencia de esquema de colores. Las calabazas de la segunda página reaccionan a la preferencia de esquema de colores del usuario. El SVG que se usa tiene círculos que representan llamas, que se amplían 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;
     }
   }
 }

¿Este retrato te mira?

Si consultas la página 10, es posible que notes algo. ¡Estás 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 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 de píxeles. El rango de salida es cuánto pueden traducir los ojos en píxeles. Luego, la posición del puntero en el eje x o y se pasa como el valor. Para obtener el punto central de los ojos mientras los mueves, los ojos se duplican. Los originales no se mueven, son transparentes y se usan como referencia.

Por último, debes vincularlos y actualizar los valores de las propiedades personalizadas de CSS en los ojos para que se puedan mover. Una función está vinculada al evento pointermove en comparación con window. A medida que se activa, los límites de cada ojo se usan para calcular los puntos centrales. Luego, la posición del puntero se asigna a valores que se establecen como valores de propiedad personalizada en 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 al CSS, los estilos pueden hacer lo que quieran con ellos. Lo mejor de esto es usar CSS clamp() para que el comportamiento sea diferente para cada ojo, de manera que cada ojo se comporte de manera diferente sin volver a tocar el 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);
 }

Lanzar hechizos

Si consultas la página seis, ¿te sientes deletreado? Esta página abarca el diseño de nuestro fantástico zorro mágico. Si mueves el puntero, es posible que veas un efecto de recorrido personalizado con el cursor. Para esto, se utiliza animación de lienzo. Un elemento <canvas> se encuentra sobre el 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;
}

Así como nuestro retrato escucha un evento pointermove en window, también lo hace nuestro elemento <canvas>. Sin embargo, cada vez que se activa el evento, creamos un objeto para animarlo en el elemento <canvas>. Estos objetos representan formas usadas en el recorrido del cursor. Tienen coordenadas y un matiz 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 en un bucle cuando se dibujan en el elemento <canvas>. Las propiedades de cada objeto le indican a nuestro elemento <canvas> dónde se deben dibujar los elementos.

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 recorrido del cursor solo debe renderizarse cuando la página está a la vista. Tenemos una IntersectionObserver que actualiza y determina las páginas que se ven. Si una página está a la vista, los objetos se renderizan como círculos en el lienzo.

Luego, hacemos un bucle sobre el array blocks y dibujamos cada parte del recorrido. Cada fotograma reduce el tamaño y altera la posición del objeto mediante la 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 se pierde la vista de la página, se quitan los objetos de escucha de eventos y se cancela el bucle de marco de animación. También se borró el array blocks.

Aquí está el recorrido del cursor en acción.

Revisión de accesibilidad

Todo es bueno crear una experiencia divertida para explorar, pero no sirve si no es accesible para los usuarios. La experiencia de Adam en esta área resultó invaluable para preparar ChromeTober para una revisión de accesibilidad antes del lanzamiento.

Estas son algunas de las áreas destacadas:

  • Asegurarse de que el código HTML utilizado fuera semántico Esto incluyó elementos como puntos de referencia adecuados, como <main> para el libro, así como el uso del elemento <article> para cada bloque de contenido y elementos <abbr> para cuando se introduzcan acrónimos. Pensar en el futuro a medida que se elaboraba el libro hizo que todo fuera más accesible. El uso de encabezados y enlaces facilita la navegación al usuario. El uso de una lista para las páginas también significa que la tecnología de asistencia anuncia la cantidad de páginas.
  • Asegurarse de que todas las imágenes usen atributos alt adecuados En el caso de los SVG intercalados, el elemento title está presente cuando es necesario.
  • Se usan atributos aria cuando mejoran la experiencia. El uso de aria-label para páginas y sus lados 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. Esto elimina la ambigüedad sobre el destino del vínculo al usuario.
  • En cuanto a los bloqueos de contenido, se puede hacer clic en toda la tarjeta (no solo en el vínculo "Más información").
  • Anteriormente, surgió el uso de un IntersectionObserver para hacer un seguimiento de las páginas que se ven. Esto tiene muchos beneficios que no solo están relacionados con el rendimiento. Las páginas que no están a la vista tendrán una animación o interacción pausada. Sin embargo, estas páginas también tienen aplicado el atributo inert. Esto significa que los usuarios que utilizan un lector de pantalla pueden explorar el mismo contenido que los usuarios videntes. El enfoque permanece dentro de la página visible, y los usuarios no pueden ir a otra página.
  • Por último, pero no menos importante, usamos las consultas de medios para respetar la preferencia de movimiento de un usuario.

A continuación, se incluye una captura de pantalla de la opinión en la que se destacan algunas de las medidas implementadas.

se identifica como en todo el libro, lo que indica que debe ser el punto de referencia principal que puedan encontrar los usuarios de tecnología de asistencia. Se puede ver más información en la captura de pantalla." ancho="800" altura="465">

Captura de pantalla del libro de ChromeTober abierto. Se proporcionan cuadros de contorno verde en torno a varios aspectos de la IU, 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 inertas. Se muestra más en la captura de pantalla.

Qué aprendimos

La motivación detrás de Chrometober no solo era destacar el contenido web de la comunidad, sino que también nos permitió probar el polyfill de la API de animaciones vinculadas a desplazamientos que está en desarrollo.

Reservamos una sesión durante la cumbre de nuestro equipo 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 hacer una lista de todos los aspectos que debíamos abordar antes de que pudiéramos comenzar a transmitir.

El equipo de CSS, IU y Herramientas para desarrolladores se encuentra sentado sobre la 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, cuando se probó el libro en dispositivos, se produjo un problema de renderización. Nuestro libro no se renderizaría como se esperaba en los dispositivos iOS. Las unidades de viewport definen el tamaño de la página, pero cuando había un recorte, afectaba el libro. La solución fue usar viewport-fit=cover en el viewport de meta:

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

En esta sesión, también se plantearon algunos problemas con el polyfill de la API. Bramus planteó estos problemas en el repositorio de polyfills. Luego, encontró soluciones a esos problemas y las fusionó en el polyfill. Por ejemplo, esta solicitud de extracción mejoró el rendimiento porque agregó almacenamiento en caché a parte del polyfill.

Captura de pantalla de una demostración abierta en Chrome. Las Herramientas para desarrolladores son abiertas y muestran una medición de rendimiento de referencia.

Captura de pantalla de una demostración abierta en Chrome. Las Herramientas para desarrolladores son abiertas y muestran una medición del rendimiento mejorada.

Listo.

Ha sido un proyecto muy divertido para trabajar que dio lugar a una experiencia de desplazamiento extravagante que destaca contenido asombroso de la comunidad. Además de eso, nos permitió probar el polyfill y proporcionar comentarios al equipo de ingeniería para mejorarlo.

Chrometober 2022 está todo listo.

Esperamos que lo hayas disfrutado. ¿Cuál es tu función favorita? Envíanos un tweet para informarnos.

Jhey sostiene una hoja de pegatinas con los personajes de Chrometober.

Incluso podrás conseguir algunas calcomanías de algún miembro del equipo si nos ves en un evento.

Foto hero de David Menidrey en Unsplash