Codelab: como criar um componente de Histórias

Este codelab ensina a criar uma experiência como os Stories do Instagram na Web. Vamos criar o componente conforme avançamos, começando com HTML, CSS e JavaScript.

Confira a postagem do blog Como criar um componente de Histórias para saber mais sobre as melhorias progressivas feitas durante a criação desse componente.

Configuração

  1. Clique em Remixar para editar para tornar o projeto editável.
  2. Abra app/index.html.

HTML

Sempre tento usar HTML semântico. Como cada amigo pode ter qualquer número de histórias, achei que seria importante usar um elemento <section> para cada amigo e um elemento <article> para cada história. Vamos começar do início. Primeiro, precisamos de um contêiner para o componente de histórias.

Adicione um elemento <div> ao <body>:

<div class="stories">

</div>

Adicione alguns elementos <section> para representar amigos:

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

Adicione alguns elementos <article> para representar as histórias:

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • Estamos usando um serviço de imagem (picsum.com) para ajudar a criar protótipos de histórias.
  • O atributo style em cada <article> faz parte de uma técnica de carregamento de marcador de posição, que você vai saber mais na próxima seção.

CSS

Nosso conteúdo está pronto para estilização. Vamos transformar esses ossos em algo com que as pessoas queiram interagir. Vamos trabalhar com a prioridade para dispositivos móveis hoje.

.stories

Para o contêiner <div class="stories">, queremos um contêiner de rolagem horizontal. Para isso, faça o seguinte:

  • Transformar o contêiner em uma grade
  • Configurar cada criança para preencher a faixa da linha
  • Definir a largura de cada elemento filho como a largura da janela de visualização de um dispositivo móvel

A grade vai continuar colocando novas colunas de 100vw de largura à direita da anterior até colocar todos os elementos HTML na marcação.

O Chrome e o DevTools são abertos com uma grade visual que mostra o layout de largura total
Chrome DevTools mostrando o overflow da coluna da grade, criando um controle deslizante horizontal.

Adicione o seguinte CSS à parte inferior de app/css/index.css:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

Agora que temos conteúdo estendido além da janela de visualização, é hora de informar ao contêiner como processá-lo. Adicione as linhas de código destacadas à regra .stories:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

Queremos a rolagem horizontal, então vamos definir overflow-x como auto. Quando o usuário rola a tela, queremos que o componente seja exibido suavemente na próxima história. Portanto, vamos usar scroll-snap-type: x mandatory. Leia mais sobre esse CSS nas seções Pontos de ajuste de rolagem do CSS e overscroll-behavior da minha postagem do blog.

É necessário que o contêiner pai e os filhos concordem com o ajuste de rolagem. Vamos resolver isso agora. Adicione o seguinte código à parte de baixo de app/css/index.css:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

Seu app ainda não funciona, mas o vídeo abaixo mostra o que acontece quando scroll-snap-type é ativado e desativado. Quando ativado, cada rolagem horizontal é fixada na próxima história. Quando desativado, o navegador usa o comportamento de rolagem padrão.

Isso vai permitir que você navegue pelos seus amigos, mas ainda temos um problema com as histórias para resolver.

.user

Vamos criar um layout na seção .user que organiza esses elementos de história filhos no lugar. Vamos usar um truque útil de empilhamento para resolver isso. Essencialmente, estamos criando uma grade 1x1 em que a linha e a coluna têm o mesmo alias de grade de [story], e cada item da grade de matérias vai tentar reivindicar esse espaço, resultando em uma pilha.

Adicione o código destacado à regra .user:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

Adicione o seguinte conjunto de regras à parte inferior de app/css/index.css:

.story {
  grid-area: story;
}

Agora, sem posicionamento absoluto, flutuações ou outras diretivas de layout que tiram um elemento do fluxo, ainda estamos no fluxo. Além disso, quase nenhum código. Olhe para isso! Isso é detalhado no vídeo e na postagem do blog.

.story

Agora precisamos estilizar o item do Story.

Anteriormente, mencionamos que o atributo style em cada elemento <article> faz parte de uma técnica de carregamento de marcador de posição:

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

Vamos usar a propriedade background-image do CSS, que permite especificar mais de uma imagem de plano de fundo. Podemos colocá-los em uma ordem para que a imagem do usuário fique na parte de cima e apareça automaticamente quando o carregamento terminar. Para ativar isso, vamos colocar o URL da imagem em uma propriedade personalizada (--bg) e usá-la no CSS para sobrepor o marcador de carregamento.

Primeiro, vamos atualizar a regra .story para substituir um gradiente por uma imagem de plano de fundo assim que o carregamento for concluído. Adicione o código destacado à regra .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

Definir background-size como cover garante que não haja espaço vazio no viewport, porque nossa imagem vai preenchê-lo. A definição de duas imagens de plano de fundo nos permite usar um truque da Web CSS chamado loading tombstone:

  • A imagem de plano de fundo 1 (var(--bg)) é o URL transmitido inline no HTML.
  • Imagem de plano de fundo 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) é um gradiente para mostrar enquanto o URL está sendo carregado)

O CSS vai substituir automaticamente o gradiente pela imagem assim que o download for concluído.

Em seguida, vamos adicionar CSS para remover alguns comportamentos, liberando o navegador para que ele seja mais rápido. Adicione o código destacado ao conjunto de regras .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • user-select: none impede que os usuários selecionem texto acidentalmente
  • O touch-action: manipulation instrui o navegador que essas interações precisam ser tratadas como eventos de toque, o que evita que o navegador tente decidir se você está clicando em um URL ou não.

Por fim, vamos adicionar um pouco de CSS para animar a transição entre as histórias. Adicione o código destacado à regra .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

A classe .seen será adicionada a uma história que precisa de uma saída. Recebi a função de easing personalizado (cubic-bezier(0.4, 0.0, 1,1)) no guia Easing do Material Design. Role até a seção Easing acelerado.

Se você tem um olho atento, provavelmente notou a declaração pointer-events: none e está se coçando agora. Eu diria que essa é a única desvantagem da solução até agora. Precisamos disso porque um elemento .seen.story ficará na parte de cima e receberá toques, mesmo que esteja invisível. Ao definir o pointer-events como none, transformamos a história do vidro em uma janela e não roubamos mais interações do usuário. Não é uma troca ruim, não é muito difícil de gerenciar aqui no nosso CSS. Não estamos fazendo malabarismos com z-index. Ainda estou feliz com isso.

JavaScript

As interações de um componente de Histórias são bastante simples para o usuário: toque na direita para avançar e na esquerda para voltar. Coisas simples para os usuários tendem a ser trabalhosas para os desenvolvedores. No entanto, vamos cuidar de muitas delas.

Configuração

Para começar, vamos calcular e armazenar o máximo de informações possível. Adicione o código a seguir a app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

Nossa primeira linha de JavaScript extrai e armazena uma referência à raiz do elemento HTML principal. A próxima linha calcula onde está o meio do elemento para que possamos decidir se um toque é para avançar ou voltar.

Estado

Em seguida, criamos um pequeno objeto com algum estado relevante para nossa lógica. Neste caso, só estamos interessados na história atual. Em nossa marcação HTML, podemos acessá-la pegando o primeiro amigo e a história mais recente dele. Adicione o código destacado ao app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

Listeners

Agora temos lógica suficiente para começar a detectar e direcionar eventos do usuário.

Camundongo

Para começar, vamos detectar o evento 'click' no contêiner de histórias. Adicione o código destacado a app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

Se um clique acontecer e não for em um elemento <article>, vamos desistir e não fazer nada. Se for um artigo, seguramos a posição horizontal do mouse ou dedo com clientX. Ainda não implementamos navigateStories, mas o argumento que ele usa especifica a direção que precisamos seguir. Se a posição do usuário for maior que a mediana, saberemos que precisamos navegar para next. Caso contrário, prev (anterior).

Teclado

Agora vamos detectar pressionamentos de teclado. Se a seta para baixo for pressionada, navegaremos para next. Se for a seta para cima, vamos para prev.

Adicione o código destacado a app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

Navegação nas Histórias

É hora de abordar a lógica de negócios única das histórias e a UX pela qual elas ficaram famosas. Isso parece complicado e grosseiro, mas acho que se você analisar linha por linha, vai perceber que é bem fácil de entender.

Inicialmente, armazenamos alguns seletores que nos ajudam a decidir se vamos rolar até um amigo ou mostrar/ocultar uma história. Como estamos trabalhando com HTML, vamos consultar a presença de amigos (usuários) ou histórias (story).

Essas variáveis vão nos ajudar a responder a perguntas como "Dada a história x, "próximo" significa passar para outra história desse mesmo amigo ou para um amigo diferente?" Fiz isso usando a estrutura de árvore que criamos, alcançando pais e filhos.

Adicione o seguinte código à parte de baixo de app/js/index.js:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

Aqui está nossa meta de lógica de negócios, o mais próximo possível da linguagem natural:

  • Decida como lidar com o toque
    • Se houver uma matéria seguinte/anterior: mostre a história
    • Se for a última/primeira história do amigo: mostrar um novo amigo
    • Se não houver uma história para seguir nessa direção: não faça nada
  • Guardar a nova story atual em state

Adicione o código destacado à função navigateStories:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

Faça um teste

  • Para visualizar o site, pressione View App. Em seguida, pressione Fullscreen tela cheia.

Conclusão

Esse é o resumo das necessidades que eu tinha com o componente. Sinta-se à vontade para criar a partir disso, orientá-lo com dados e, em geral, torná-lo seu!