Criando o Chrometober!

Como o livro de rolagem ganhou vida por 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 de consultas de contêiner, mas este ano estamos apresentando a API de animações vinculadas à rolagem do CSS.

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 extravagante, destacando a API de animações vinculadas à rolagem. No entanto, embora fosse uma ideia interessante, 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 diferentes técnicas e ferramentas combinadas. 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 pelas quais um usuário poderia rolar o caminho ao longo de uma forma de storyboard. Inspirados pelos 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 e 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. No entanto, decidi deixar essa ideia por me preocupar em como poderíamos tornar essa experiência ótima para 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 notar.

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. Dessa forma, poderíamos combiná-las em cenas e depois escolher o que demos vida a elas.

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 rola a tela.

Conhecer 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. Com isso, é possível testar o formato da API durante o desenvolvimento. Isso significa que podemos usar essa API hoje, e projetos divertidos como esse geralmente são um ótimo lugar para testar recursos experimentais e enviar feedback. Veja o que aprendemos e o feedback que fornecemos mais adiante neste 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, porque isso pode acontecer mais tarde. As animações vinculadas à rolagem também se enquadram 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 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 o ViewTimeline seria uma ótima opção para capturar viradas de página em um livro.

Como prototipar a mecânica

Depois de alguns testes, consegui colocar um protótipo de livro em funcionamento. 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 voltar a usá-lo no 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 restante 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 serem usadas 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 abertura de página 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 três tem esta aparência:

---
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 de natureza atômica. Eles são criados com base em uma coleção de recursos. A página três apresenta 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 usados 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 a coruja estão localizados 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 a relaciona à caixa delimitadora do objeto no SVG. A coruja é escalonada na parte inferior central, por isso é usada a 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 com 80% de rotação, comece a mover a coruja. Quando chegar a 90%, a coruja vai terminar a tradução.

Recursos do livro

Agora você já viu a abordagem para criar uma página e como funciona a arquitetura do projeto. 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 do livro; por exemplo, o taco que voa para dentro e para fora ao virar as páginas.

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

Aqueles com um olhar aguçado podem ter notado o uso de elementos <source> ao discutir os planos de fundo da página anteriormente. 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 alterações 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 usa 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 é o quanto os olhos podem traduzir em pixels. 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.

Então, vamos vincular e atualizar os valores da propriedade personalizada do CSS nos olhos para que eles se movam. 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 personalizada 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 passados 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);
 }

Lance de 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á ver um efeito personalizado de rastro do cursor. Usamos a animação da 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;
}

Da mesma forma que nosso retrato detecta um evento pointermove em window, o elemento <canvas> também detecta. 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 de antes é usada novamente, já que podemos usá-la para mapear o 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 visualização, os objetos serão renderizados como círculos na tela.

Em seguida, fazemos um loop na matriz blocks e desenhamos cada parte da trilha. 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 diminuir 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.

Avaliação 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 incluiu coisas como elementos de ponto de referência apropriados, como <main> para o livro, além do uso do elemento <article> para cada bloco de conteúdo e elementos <abbr> em que as siglas são introduzidas. Pensar no futuro à medida que o livro foi criado tornou tudo mais acessível. 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 assistiva.
  • Garantir que todas as imagens usem atributos alt apropriados. 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 remove a ambiguidade sobre para onde o link direcionará o usuário.
  • No assunto sobre bloqueios de conteúdo, a capacidade de clicar em todo o card, não apenas no link "Leia mais", está disponível.
  • O uso de um IntersectionObserver para rastrear quais páginas estão em exibição foi mencionado anteriormente. Isso gera muitos benefícios que não estão relacionados apenas ao desempenho. As páginas que não estiverem visíveis 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.

Veja uma captura de tela da avaliação 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 do Chrometober aberto. Caixas verdes com contornos 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, imagens têm texto alternativo. Outro exemplo é um rótulo de acessibilidade que declara que as páginas fora da tela estão inativas. Há mais detalhes na captura de tela.

O que descobrimos

O Chrometober foi criado não 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 nossa conferência de equipes em Nova York para testar o projeto e resolver os problemas que surgiram. A contribuição da equipe foi inestimável. Também foi uma ótima oportunidade para listar tudo o que precisava ser resolvido antes de iniciarmos.

As equipes de CSS, UI e DevTools estão sentadas em volta da mesa em uma sala de conferências. Una está em frente a um quadro branco coberto de notas adesivas. Outros membros da equipe sentam-se ao redor da mesa com refeições e laptops.

Por exemplo, o teste do livro em dispositivos causou um problema de renderização. Nosso livro não é renderizado 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 envio teve um ganho de desempenho ao adicionar armazenamento em cache a parte do polyfill.

Captura de tela de uma demonstração aberta no Chrome. As Ferramentas para desenvolvedores 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 aprimorada.

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