Codelab: como criar um componente de Histórias

Este codelab ensina a criar uma experiência como o Instagram Stories na Web. Criaremos 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.

Instalação

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

HTML

Sempre pretendo usar o HTML semântico. Como cada amigo pode ter qualquer número de histórias, achei que seria interessante usar um elemento <section> para cada amigo e um elemento <article> para cada história. Mas 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 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.
  • O atributo style em cada <article> faz parte de uma técnica de carregamento de marcadores de posição. Você vai conhecer mais sobre ela na próxima seção.

CSS

Nosso conteúdo está pronto para ter estilo. Vamos transformar esses ossos em algo com que as pessoas vão querer interagir. Hoje, trabalharemos priorizando os dispositivos móveis.

.stories

Para o contêiner <div class="stories">, queremos um contêiner de rolagem horizontal. É possível fazer isso da seguinte maneira:

  • Como transformar o contêiner em uma Grade.
  • Como configurar cada filho para preencher a faixa de linhas
  • Tornar a largura de cada filho a largura de uma janela de visualização de dispositivo móvel

A grade continuará colocando novas colunas com largura de 100vw à direita da anterior, até que todos os elementos HTML sejam colocados na sua marcação.

Chrome e DevTools abertos com um visual de grade mostrando o layout de largura total
Chrome DevTools mostrando o estouro da coluna de grade, criando uma rolagem 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 que vai além da janela de visualização, é hora de dizer ao contêiner como lidar com ele. Adicione as linhas de código destacadas ao seu conjunto de regras .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 rolagem horizontal, então vamos definir overflow-x como auto. Quando o usuário rola a tela, queremos que o componente descanse suavemente na próxima story, por isso, usaremos 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, então vamos processar isso agora. Adicione o código abaixo à 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 o scroll-snap-type está ativado e desativado. Quando ativada, cada rolagem horizontal é ajustada para a próxima matéria. Quando desativada, o navegador usa o comportamento de rolagem padrão.

Isso fará com que você percorra seus amigos, mas ainda temos um problema com as histórias para resolver.

.user

Vamos criar um layout na seção .user que organize esses elementos da história filha no lugar. Vamos usar um truque prático de empilhar para resolver isso. Estamos essencialmente 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 história tentará reivindicar esse espaço, resultando em uma pilha.

Adicione o código destacado ao conjunto de regras .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, flutuantes ou outras diretivas de layout que tiram um elemento do fluxo, ainda estamos no fluxo. Além disso, é como quase nenhum código. Olha só! Isso é detalhado no vídeo e na postagem do blog com mais detalhes.

.story

Agora só precisamos estilizar o item da história em si.

Anteriormente, mencionamos que o atributo style em cada elemento <article> faz parte de uma técnica de carregamento de marcadores 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 por cima e apareça automaticamente quando o carregamento for concluído. Para ativar esse recurso, colocaremos o URL da imagem em uma propriedade personalizada (--bg) e o usaremos no CSS para adicionar o marcador de carregamento.

Primeiro, vamos atualizar o conjunto de regras .story para substituir um gradiente por uma imagem de plano de fundo assim que o carregamento for concluído. 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));
}

Definir background-size como cover garante que não haja espaço vazio na janela de visualização, porque a imagem a preencherá. A definição de duas imagens de plano de fundo nos permite usar um bom truque da Web de CSS chamado de carregamento de tombstone:

  • A imagem de plano de fundo 1 (var(--bg)) é o URL que transmitimos inline no HTML
  • Imagem de plano de fundo 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) é um gradiente a ser exibido enquanto o URL está carregando

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

Em seguida, adicionaremos CSS para remover algum comportamento, liberando o navegador para que ele funcione 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
  • touch-action: manipulation instrui o navegador que essas interações precisam ser tratadas como eventos de toque, o que libera o navegador de tentar decidir se você está clicando em um URL ou não.

Por último, vamos adicionar um pouco de CSS para animar a transição entre as histórias. 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;

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

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

A classe .seen será adicionada a uma story que precisa de uma saída. Encontrei a função de easing personalizado (cubic-bezier(0.4, 0.0, 1,1)) do guia Easing (em inglês) do Material Design (role a tela até a seção Easing aumentado).

Se você está atento, provavelmente notou a declaração pointer-events: none e está coçando a cabeça agora. Eu diria que essa é a única desvantagem da solução até agora. Isso é necessário porque um elemento .seen.story estará na parte superior e receberá toques, mesmo que esteja invisível. Ao definir pointer-events como none, transformamos a história do vidro em uma janela e não roubamos mais interações do usuário. Nada mau, nada difícil de gerenciar no CSS agora. Não estamos fazendo malabarismos com z-index. ainda tô me sentindo bem.

JavaScript

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

Instalação

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

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

A primeira linha de JavaScript captura 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 vai avançar ou retroceder.

Estado

Em seguida, vamos criar um objeto pequeno com algum estado relevante para nossa lógica. Nesse caso, estamos interessados apenas na história atual. Na nossa marcação HTML, podemos acessá-la pegando o primeiro amigo e sua história mais recente. 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
}

Listeners

Já temos lógica suficiente para começar a detectar e direcionar eventos de usuários.

Rato

Vamos começar detectando o evento 'click' no nosso 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 estiver em um elemento <article>, vamos ignorar e não fazer nada. No caso de um artigo, seguramos a posição horizontal do mouse ou do dedo com clientX. Ainda não implementamos navigateStories, mas o argumento que ele toma especifica para qual direção precisamos ir. Se essa posição do usuário for maior que a mediana, saberemos que precisamos navegar para next. Caso contrário, será prev (anterior).

Teclado

Agora, vamos ouvir os pressionamentos do teclado. Se a seta para baixo for pressionada, vamos navegar para next. Se for a seta para cima, acessaremos 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 das Histórias

É hora de abordar a lógica de negócios única das histórias e a UX por que elas se tornaram famosas. Isso parece robusto e complicado, mas acho que se você seguir linha por linha, vai achar que é bem simples.

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

Essas variáveis nos ajudarão a responder a perguntas como: "dada a história x, "outra" significa passar para outra história desse mesmo amigo ou para outro amigo?" Fiz isso usando a estrutura de árvore que construímos, alcançando pais e filhos.

Adicione o código abaixo à 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
}

Esta é nossa meta de lógica de negócios, o mais próxima possível da linguagem natural:

  • Decida como lidar com o toque
    • Se houver uma história seguinte/anterior: mostrar essa história
    • Se for a última ou a primeira história do amigo: mostre um novo amigo
    • Se não houver uma história para ir nessa direção: não fazer nada
  • Colocar a nova história 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
    }
  }
}

Testar

  • Para visualizar o site, pressione Ver app. Em seguida, pressione Tela cheia modo tela cheia.

Conclusão

Esse é o resumo sobre as necessidades que tive com o componente. Sinta-se à vontade para criar com base nela, guiá-lo com dados e, em geral, torná-lo seu!