Criando o Chrometober!

Como o livro de rolagem ganhou vida para compartilhar dicas e truques divertidos e assustadores neste Chrometober.

Seguindo o Designcember, criamos o Chrometober deste ano para destacar e compartilhar conteúdo da Web da comunidade e da equipe do Chrome. O Designcember mostrou o uso das consultas de contêiner, mas este ano estamos mostrando a API CSS de animações vinculadas ao rolagem.

Confira a experiência de rolagem do livro em web.dev/chrometober-2022.

Visão geral

O objetivo do projeto era oferecer uma experiência inusitada destacando a API de animações vinculadas ao rolagem. No entanto, embora fosse uma ideia ousada, a experiência também precisava ser responsiva e acessível. O projeto também foi uma ótima maneira de testar o polyfill da API que está em desenvolvimento ativo, além de tentar combinar diferentes técnicas e ferramentas. E tudo com um tema festivo de Halloween!

Nossa estrutura de equipe era assim:

Como criar uma experiência de scrollytelling

As ideias para o Chrometober começaram a surgir na nossa primeira reunião externa da equipe, em maio de 2022. Uma coleção de rabiscos nos fez pensar em maneiras de um usuário rolar por alguma forma de storyboard. Inspirados em videogames, consideramos uma experiência de rolagem por cenas como cemitérios e uma casa mal-assombrada.

Um caderno está sobre uma mesa com vários rabiscos relacionados ao projeto.

Foi empolgante ter a liberdade criativa de levar meu primeiro projeto do Google em uma direção inesperada. Esse foi um protótipo inicial de como um usuário pode navegar pelo conteúdo.

Conforme o usuário rola para os lados, os blocos giram e são redimensionados. Mas decidi abandonar essa ideia por preocupação em como poderíamos tornar essa experiência ótima para os usuários em dispositivos de todos os tamanhos. Em vez disso, me inclinei para o design de algo que eu tinha feito no passado. Em 2020, tive a sorte de ter acesso ao ScrollTrigger do GreenSock para criar demonstrações de lançamento.

Uma das demos que criei foi um livro em CSS 3D em que as páginas viravam conforme você rolava a tela. Isso parecia muito mais apropriado para o que queríamos no Chrometober. A API de animações vinculadas ao rolagem é uma troca perfeita para essa funcionalidade. Ele também funciona bem com scroll-snap, como você vai ver.

Nosso ilustrador do projeto, Tyler Reed, foi ótimo em alterar o design conforme mudamos as ideias. Tyler fez um trabalho fantástico ao transformar todas as ideias criativas que recebeu em realidade. Foi muito divertido discutir ideias em grupo. Uma grande parte do que queríamos era ter os recursos divididos em blocos isolados. Assim, podemos criar cenas e escolher o que queremos mostrar.

Uma das cenas de composição mostra uma cobra, um caixão com braços saindo dele, uma raposa com uma varinha em um caldeirão, uma árvore com um rosto assustador e um gárgula segurando uma lanterna de abóbora.

A ideia principal era que, à medida que o usuário avançasse no livro, ele pudesse acessar blocos de conteúdo. Eles também podiam interagir com traços de fantasia, incluindo os ovos de Páscoa que criamos na experiência. Por exemplo, um retrato em uma casa mal-assombrada, cujos olhos seguiam o cursor, ou animações sutis acionadas por consultas de mídia. Essas ideias e recursos seriam animados ao rolar a página. Uma das primeiras ideias foi um coelho zumbi que se elevasse e se movesse ao longo do eixo x quando o usuário rolasse a tela.

Familiarizar-se com a API

Antes de começarmos a brincar com recursos individuais e ovos de Páscoa, precisávamos de um livro. Por isso, decidimos transformar isso em uma oportunidade para testar o conjunto de recursos da API de animações vinculadas ao rolagem do CSS. No momento, a API de animações vinculadas ao rolagem não é compatível com nenhum navegador. No entanto, durante o desenvolvimento da API, os engenheiros da equipe de interações trabalharam em um polyfill. Isso oferece uma maneira de testar a forma da API durante o desenvolvimento. Isso significa que podemos usar essa API hoje, e projetos divertidos como esse são ótimos lugares para testar recursos experimentais e enviar feedback. Confira o que aprendemos e o feedback que conseguimos fornecer mais adiante no artigo.

De forma geral, é possível usar essa API para vincular animações ao rolagem. É importante observar que não é possível acionar uma animação ao rolar a tela. Isso pode acontecer mais tarde. As animações vinculadas à rolagem também se dividem em duas categorias principais:

  1. Aqueles que reagem à posição de rolagem.
  2. Aquelas que reagem à posição de um elemento no contêiner de rolagem.

Para criar o segundo, usamos um ViewTimeline aplicado por uma propriedade animation-timeline.

Confira um exemplo de como usar ViewTimeline no 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;
 }
}

Criamos uma ViewTimeline com view-timeline-name e definimos o eixo dela. Neste exemplo, block se refere a block lógico. A animação é vinculada ao rolagem com a propriedade animation-timeline. animation-delay e animation-end-delay (no momento da escrita) são as fases que definimos.

Essas fases definem os pontos em que a animação precisa ser vinculada em relação à posição de um elemento no contêiner de rolagem. No nosso exemplo, dizemos para iniciar a animação quando o elemento entra (enter 0%) no contêiner de rolagem. E termina quando cobre 50% (cover 50%) do contêiner de rolagem.

Confira nossa demonstração em ação:

Você também pode vincular uma animação ao elemento que está se movendo na viewport. Para fazer isso, defina o animation-timeline como o view-timeline do elemento. Isso é bom para cenários como animações de lista. O comportamento é semelhante à forma como você pode animar elementos ao entrar usando 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;
  }
}

Com isso, o "Mover" aumenta de tamanho à medida que entra na viewport, acionando a rotação do "Spinner".

O que descobri com a experimentação foi que a API funciona muito bem com o scroll-snap. O ajuste de rolagem combinado com ViewTimeline seria uma ótima opção para ajustar as viradas de página em um livro.

Prototipagem da mecânica

Depois de algumas experiências, consegui fazer um protótipo de livro funcionar. Você rola na horizontal para virar as páginas do livro.

Na demonstração, você pode ver os diferentes acionadores destacados com bordas tracejadas.

A marcação é mais ou menos assim:

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

Ao rolar a tela, as páginas do livro são viradas, mas são abertas ou fechadas. Isso depende do alinhamento de rolagem dos acionadores.

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

Desta vez, não conectamos o ViewTimeline no CSS, mas usamos a API Web Animations no JavaScript. Isso tem a vantagem adicional de poder iterar um conjunto de elementos e gerar o ViewTimeline necessário, em vez de criá-los manualmente.

const triggers = document.querySelectorAll("[data-scroll-trigger]")

const commonProps = {
  delay: { phase: "enter", percent: CSS.percent(0) },
  endDelay: { phase: "enter", percent: CSS.percent(100) },
  fill: "both"
}

const setupPage = (trigger, index) => {
  const target = document.querySelector(
    `[data-scroll-target="${trigger.getAttribute("data-scroll-trigger")}"]`
  );

  const viewTimeline = new ViewTimeline({
    subject: trigger,
    axis: 'inline',
  });

  target.animate(
    [
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`
      },
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`,
        offset: 0.75
      },
      {
        transform: `translateZ(${(triggers.length - index) * -1}px)`
      }
    ],
    {
      timeline: viewTimeline,
      commonProps,
    }
  );
  target.querySelector(".page__paper").animate(
    [
      {
        transform: "rotateY(0deg)"
      },
      {
        transform: "rotateY(-180deg)"
      }
    ],
    {
      timeline: viewTimeline,
      commonProps,
    }
  );
};

const triggers = document.querySelectorAll('[data-scroll-trigger]')
triggers.forEach(setupPage);

Para cada acionador, geramos um ViewTimeline. Em seguida, animamos a página associada ao acionador usando esse ViewTimeline. Isso vincula a animação da página à rolagem. Para nossa animação, estamos girando um elemento da página no eixo y para virar a página. Também traduzimos a página no eixo z para que ela se comporte como um livro.

Como tudo funciona em conjunto

Depois de desenvolver o mecanismo do livro, pude me concentrar em dar vida às ilustrações de Tyler.

Astro

A equipe usou o Astro para o Designcember em 2021, e eu queria usá-lo de novo para o Chrometober. A experiência do desenvolvedor de poder dividir as coisas em componentes é adequada para esse projeto.

O livro em si é um componente. Ele também é uma coleção de componentes de página. Cada página tem dois lados e tem planos de fundo. Os filhos de um lado da página são componentes que podem ser adicionados, removidos e posicionados com facilidade.

Criar um livro

Era importante para mim facilitar o gerenciamento dos blocos. Também queria facilitar a contribuição do resto da equipe.

As páginas de alto nível são definidas por uma matriz de configuração. Cada objeto de página na matriz define o conteúdo, o plano de fundo e outros metadados de uma 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 */
]

Eles são transmitidos para o componente Book.

<Book pages={pages} />

O componente Book é onde o mecanismo de rolagem é aplicado e as páginas do livro são criadas. O mesmo mecanismo do protótipo é usado, mas compartilhamos várias instâncias de ViewTimeline criadas globalmente.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

Assim, podemos compartilhar as linhas do tempo para uso em outros lugares, em vez de recriar. Falaremos mais sobre isso mais tarde.

Composição da página

Cada página é um item de lista dentro de uma 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>

E a configuração definida é transmitida para cada instância Page. As páginas usam o recurso de slot do Astro para inserir conteúdo em cada uma delas.

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

Esse código é usado principalmente para configurar a estrutura. Os colaboradores podem trabalhar no conteúdo do livro na maior parte sem precisar tocar nesse código.

Pano de fundo

A mudança criativa para um livro tornou a divisão das seções muito mais fácil, e cada página do livro é uma cena retirada do design original.

Ilustração de página dupla do livro que mostra uma macieira em um cemitério. O cemitério tem várias lápides e há um morcego no céu em frente a uma lua grande.

Como decidimos uma proporção para o livro, o plano de fundo de cada página pode ter um elemento de imagem. Definir esse elemento com 200% de largura e usar object-position com base no lado da página resolve o problema.

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

Conteúdo da página

Vamos criar uma das páginas. A página três mostra uma coruja que aparece em uma árvore.

Ele é preenchido com um componente PageThree, conforme definido na configuração. É um componente Astro (PageThree.astro). Esses componentes se parecem com arquivos HTML, mas têm uma cerca de código na parte de cima semelhante ao frontmatter. Isso nos permite fazer coisas como importar outros componentes. O componente da página 3 fica assim:

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

Novamente, as páginas são atômicas por natureza. Eles são criados com base em uma coleção de recursos. A página três tem um bloco de conteúdo e a coruja interativa, então há um componente para cada um.

Os blocos de conteúdo são os links para o conteúdo encontrado no livro. Elas também são controladas por um objeto de configuração.

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

Essa configuração é importada quando os blocos de conteúdo são necessários. Em seguida, a configuração de bloco relevante é transmitida para o componente ContentBlock.

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

Também há um exemplo aqui de como usamos o componente da página como um lugar para posicionar o conteúdo. Aqui, um bloco de conteúdo é posicionado.

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

No entanto, os estilos gerais de um bloco de conteúdo são localizados junto com o código do 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%;
}

A coruja é um recurso interativo, um dos muitos neste projeto. Este é um exemplo pequeno e útil que mostra como usamos a ViewTimeline compartilhada que criamos.

Em um nível alto, nosso componente de coruja importa alguns SVGs e os inlines usando o fragmento do Astro.

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

E os estilos para posicionar nossa coruja são localizados junto com o código do componente.

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

Há um estilo extra que define o comportamento transform da coruja.

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

O uso de transform-box afeta o transform-origin. Ele é relativo à caixa delimitadora do objeto no SVG. A coruja aumenta de tamanho a partir do centro inferior, daí o uso de transform-origin: 50% 100%.

A parte divertida é quando vinculamos a coruja a um dos nossos ViewTimelines gerados:

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

Neste bloco de código, fazemos duas coisas:

  1. Verifique as preferências de movimento do usuário.
  2. Se não tiver preferência, vincule uma animação da coruja para rolar.

Na segunda parte, a coruja é animada no eixo y usando a API Web Animations. A propriedade de transformação individual translate é usada e vinculada a um ViewTimeline. Ela está vinculada a CHROMETOBER_TIMELINES[1] pela propriedade timeline. Esse é um ViewTimeline gerado para as viradas de página. Isso vincula a animação da coruja à virada de página usando a fase enter. Ele define que, quando a página estiver 80% virada, comece a mover a coruja. Quando chegar a 90%, a coruja vai terminar a tradução.

Recursos do livro

Agora você já sabe como criar uma página e como a arquitetura do projeto funciona. Você pode ver como ele permite que os colaboradores comecem a trabalhar em uma página ou recurso de sua escolha. Vários recursos do livro têm animações vinculadas à virada de página, como o morcego que voa para dentro e para fora nas viradas de página.

Ele também tem elementos que são ativados por animações CSS.

Depois que os blocos de conteúdo foram incluídos no livro, houve tempo para usar a criatividade com outros recursos. Isso proporcionou a oportunidade de gerar algumas interações diferentes e tentar maneiras diferentes de implementar as coisas.

Como manter a responsividade

As unidades de janela de visualização responsiva definem o tamanho do livro e dos recursos. No entanto, manter as fontes responsivas foi um desafio interessante. As unidades de consulta de contêiner são uma boa opção. No entanto, elas ainda não têm suporte em todos os lugares. O tamanho do livro está definido, então não precisamos de uma consulta de contêiner. Uma unidade de consulta de contêiner inline pode ser gerada com CSS calc() e usada para dimensionar fontes.


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

Abóboras brilhando à noite

Quem tem um olhar atento pode ter notado o uso de elementos <source> ao discutir os planos de fundo da página. Una queria uma interação que reagisse à preferência de esquema de cores. Como resultado, os planos de fundo oferecem suporte aos modos claro e escuro com variantes diferentes. Como é possível usar consultas de mídia com o elemento <picture>, essa é uma ótima maneira de fornecer dois estilos de plano de fundo. O elemento <source> consulta a preferência do esquema de cores e mostra o plano de fundo adequado.

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

Você pode introduzir outras mudanças com base nessa preferência de esquema de cores. As abóboras na página 2 reagem à preferência de esquema de cores do usuário. O SVG usado tem círculos que representam chamas, que são dimensionadas e animadas no modo escuro.

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

O retrato está olhando para você?

Se você conferir a página 10, vai notar algo. Você está sendo observado! Os olhos do retrato vão seguir o cursor conforme você se move pela página. O truque aqui é mapear a localização do ponteiro para um valor de tradução e transmiti-lo ao 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)
 }

Esse código recebe intervalos de entrada e saída e mapeia os valores fornecidos. Por exemplo, esse uso daria o valor 625.

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

No modo retrato, o valor de entrada é o ponto central de cada olho, mais ou menos uma distância de pixels. O intervalo de saída é a quantidade de pixels que os olhos podem traduzir. Em seguida, a posição do ponteiro no eixo x ou y é transmitida como o valor. Para encontrar o ponto central dos olhos enquanto eles se movem, eles são duplicados. Os originais não se movem, são transparentes e usados como referência.

Em seguida, é necessário vincular e atualizar os valores da propriedade personalizada do CSS nos olhos para que eles possam se mover. Uma função está vinculada ao evento pointermove em relação ao window. Quando isso acontece, os limites de cada olho são usados para calcular os pontos centrais. Em seguida, a posição do ponteiro é mapeada para valores definidos como valores de propriedade personalizados nos olhos.

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

Depois que os valores são transmitidos para o CSS, os estilos podem fazer o que quiserem com eles. A melhor parte é usar o CSS clamp() para tornar o comportamento diferente para cada olho, para que você possa fazer com que cada olho se comporte de maneira diferente sem tocar no JavaScript novamente.

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

Como lançar feitiços

Se você conferir a página 6, vai se sentir encantado? Esta página mostra o design da nossa raposa mágica. Se você mover o ponteiro, poderá notar um efeito de rastro de cursor personalizado. Isso usa a animação de tela. Um elemento <canvas> fica acima do restante do conteúdo da página com pointer-events: none. Isso significa que os usuários ainda podem clicar nos blocos de conteúdo abaixo.

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

Assim como nosso retrato detecta um evento pointermove em window, nosso elemento <canvas> também faz isso. No entanto, sempre que o evento é acionado, criamos um objeto para animar no elemento <canvas>. Esses objetos representam formas usadas no rastro do cursor. Eles têm coordenadas e uma cor aleatória.

A função mapRange anterior é usada novamente, porque podemos usá-la para mapear a delta do ponteiro para size e rate. Os objetos são armazenados em uma matriz que é repetida quando os objetos são desenhados para o elemento <canvas>. As propriedades de cada objeto informam ao elemento <canvas> onde as coisas precisam ser desenhadas.

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 desenhar na tela, um loop é criado com requestAnimationFrame. O rastro do cursor só precisa ser renderizado quando a página estiver visível. Temos um IntersectionObserver que atualiza e determina quais páginas estão em exibição. Se uma página estiver em exibição, os objetos serão renderizados como círculos na tela.

Em seguida, fazemos um loop na matriz blocks e desenhamos cada parte do percurso. Cada frame reduz o tamanho e altera a posição do objeto pelo rate. Isso produz o efeito de queda e escalonamento. Se o objeto encolher completamente, ele será removido da matriz 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 a página sair da visualização, os listeners de eventos serão removidos e o loop de frames de animação será cancelado. A matriz blocks também é limpa.

Confira a trilha do cursor em ação.

Análise de acessibilidade

É bom criar uma experiência divertida para explorar, mas não é bom se ela não for acessível aos usuários. A experiência de Adam nessa área foi fundamental para preparar o Chrometober para uma análise de acessibilidade antes do lançamento.

Algumas das áreas importantes cobertas:

  • Garantir que o HTML usado era semântico. Isso inclui elementos de referência adequados, como <main> para o livro, o uso do elemento <article> para cada bloco de conteúdo e elementos <abbr> em que são introduzidos acrônimos. Pensar com antecedência enquanto o livro era criado tornou as coisas mais acessíveis. O uso de títulos e links facilita a navegação do usuário. O uso de uma lista para as páginas também significa que o número de páginas é anunciado pela tecnologia adaptativa.
  • Garanta que todas as imagens usem os atributos alt adequados. Para SVGs inline, o elemento title está presente quando necessário.
  • Use os atributos aria quando eles melhorarem a experiência. O uso de aria-label para páginas e lados delas informa ao usuário em qual página ele está. O uso de aria-describedBy nos links "Leia mais" comunica o texto do bloco de conteúdo. Isso elimina a ambiguidade sobre para onde o link vai levar o usuário.
  • Em relação aos blocos de conteúdo, é possível clicar no card inteiro, e não apenas no link "Leia mais".
  • O uso de um IntersectionObserver para rastrear quais páginas estão em exibição foi mencionado anteriormente. Isso tem muitos benefícios que não estão relacionados apenas à performance. As páginas que não estiverem em exibição terão qualquer animação ou interação pausada. No entanto, essas páginas também têm o atributo inert aplicado. Isso significa que os usuários que usam um leitor de tela podem acessar o mesmo conteúdo que os usuários com visão. O foco permanece na página em exibição, e os usuários não podem alternar para outra página.
  • Por último, mas não menos importante, usamos consultas de mídia para respeitar a preferência do usuário por movimento.

Confira uma captura de tela da análise destacando algumas das medidas em vigor.

O elemento é identificado como o livro inteiro, indicando que ele deve ser o principal ponto de referência para os usuários de tecnologia adaptativa. Mais informações estão descritas na captura de tela." width="800" height="465">

Captura de tela do livro aberto do Chrometober. Caixas verdes com contorno são fornecidas em vários aspectos da interface, descrevendo a funcionalidade de acessibilidade pretendida e os resultados da experiência do usuário que a página vai oferecer. Por exemplo, as imagens têm texto alternativo. Outro exemplo é um rótulo de acessibilidade que declara que as páginas fora da tela estão inativas. Mais informações estão descritas na captura de tela.

O que descobrimos

O Chrometober não foi apenas para destacar o conteúdo da Web da comunidade, mas também para testar o polyfill da API de animações vinculadas ao rolagem que está em desenvolvimento.

Reservamos uma sessão durante a nossa conferência de equipe em Nova York para testar o projeto e resolver os problemas que surgiram. A contribuição da equipe foi muito importante. Também foi uma ótima oportunidade para listar tudo o que precisava ser resolvido antes de ativarmos.

Equipes de CSS, UI e DevTools sentadas ao redor de uma mesa em uma sala de conferências. Una está em frente a um quadro branco coberto de notas autoadesivas. Outros membros da equipe sentam em volta da mesa com bebidas e laptops.

Por exemplo, o teste do livro em dispositivos causou um problema de renderização. O livro não renderizava como esperado em dispositivos iOS. As unidades de janela de visualização definem o tamanho da página, mas quando um entalhe estava presente, ele afetou o livro. A solução foi usar viewport-fit=cover na janela de visualização meta:

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

Essa sessão também levantou alguns problemas com o polyfill da API. Bramus levantou esses problemas no repositório de polyfill. Ele encontrou soluções para esses problemas e os mesclou no polyfill. Por exemplo, esta solicitação de pull fez um ganho de desempenho adicionando armazenamento em cache a parte do polyfill.

Uma captura de tela de uma demonstração aberta no Chrome. As ferramentas do desenvolvedor estão abertas e mostram uma medição de desempenho de referência.

Uma captura de tela de uma demonstração aberta no Chrome. As ferramentas para desenvolvedores estão abertas e mostram uma medição de desempenho melhorada.

Pronto!

Foi um projeto muito divertido de trabalhar, resultando em uma experiência de rolagem inusitada que destaca o conteúdo incrível da comunidade. Além disso, foi ótimo para testar o polyfill e fornecer feedback à equipe de engenharia para ajudar a melhorar o polyfill.

O Chrometober 2022 acabou.

Esperamos que você tenha gostado! Qual é seu recurso favorito? Envie um tweet e nos conte.

Jhey segurando uma folha de adesivos dos personagens do Chrometober.

Você pode até mesmo pegar alguns adesivos de um dos membros da equipe se nos encontrar em um evento.

Foto principal de David Menidrey no Unsplash