Este codelab ensina a criar uma experiência como os Stories do Instagram na Web. Vamos criar o componente à medida que avançamos, começando com HTML, depois CSS e JavaScript.
Confira minha postagem do blog Como criar um componente de Stories para saber mais sobre as melhorias progressivas feitas durante a criação desse componente.
Configuração
- Clique em Remixar para editar para tornar o projeto editável.
- 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 interessante 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 nosso
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 imagens (
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 conhecer melhor na próxima seção.
CSS
Nosso conteúdo está pronto para ser estilizado. Vamos transformar esses ossos em algo com que as pessoas vão querer interagir. Hoje vamos trabalhar com a priorização de dispositivos móveis.
.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
- Definir cada filho para preencher a faixa da linha
- Definir a largura de cada filho como a largura de uma janela de visualização de dispositivo móvel
A grade continua colocando novas colunas de 100vw
de largura à direita da anterior até que todos os elementos HTML na sua marcação sejam colocados.

Adicione o seguinte CSS à parte de baixo de app/css/index.css
:
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
}
Agora que temos conteúdo que se estende além da janela de visualização, é hora de informar ao
contêiner como lidar com isso. Adicione as linhas de código destacadas ao 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 rolar a tela, queremos que o componente fique suavemente na próxima história. Por isso, 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 principal 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;
}
O 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
é ajustada para o próximo story. Quando desativado, o navegador usa o comportamento de rolagem padrão.
Isso vai fazer você rolar a tela pelos 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 filhos de
story. Vamos usar um truque de empilhamento útil para resolver isso.
Estamos essencialmente criando uma grade 1x1 em que a linha e a coluna têm o mesmo alias de grade [story]
, e cada item da grade de histórias vai 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 de baixo de app/css/index.css
:
.story {
grid-area: story;
}
Agora, sem posicionamento absoluto, elementos flutuantes ou outras diretivas de layout que tiram um elemento do fluxo, ainda estamos no fluxo. Além disso, é quase nenhum código. Olha só! Isso é explicado em mais detalhes no vídeo e na postagem do blog.
.story
Agora só precisamos estilizar o próprio item da história.
Antes, 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á-las em uma ordem para que a foto do usuário fique na parte de cima e apareça automaticamente quando terminar de carregar. Para
ativar isso, vamos colocar o URL da imagem em uma propriedade personalizada (--bg
) e usá-lo
no CSS para criar camadas com o marcador de posição de carregamento.
Primeiro, vamos atualizar o conjunto de regras .story
para substituir um gradiente por uma imagem de plano de fundo
depois que ele terminar de carregar. 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 nossa imagem vai preenchê-la. Definir duas imagens de plano de fundo
permite usar um truque da Web CSS chamado lápide de carregamento:
- 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 para mostrar enquanto o URL está sendo carregado
O CSS vai substituir automaticamente o gradiente pela imagem quando ela terminar de ser baixada.
Em seguida, vamos adicionar um pouco de CSS para remover alguns comportamentos, liberando o navegador para se mover 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 por acidentetouch-action: manipulation
instrui o navegador a tratar essas interações como eventos de toque, o que libera o navegador de tentar 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 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 história que precisa de uma saída.
A função de aceleração personalizada (cubic-bezier(0.4, 0.0, 1,1)
) foi extraída do guia Easing (em inglês) do Material Design. Role até a seção Aceleração acelerada.
Se você tem um olhar atento, provavelmente notou a declaração pointer-events: none
e está confuso agora. Diria que essa é a única desvantagem da solução até agora. Isso é necessário porque um elemento .seen.story
fica na parte de cima e recebe 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 tão ruim, não é tão difícil de gerenciar aqui no nosso CSS agora. Não estamos fazendo malabarismo com z-index
. Ainda estou confiante.
JavaScript
As interações de um componente de matérias são bem simples para o usuário: toque à direita para avançar e à esquerda para voltar. Coisas simples para os usuários costumam ser difíceis para os desenvolvedores. Mas vamos cuidar de muita coisa.
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 captura e armazena uma referência à raiz do elemento HTML principal. A próxima linha calcula onde fica 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, estamos interessados apenas na história atual. Na nossa marcação HTML, podemos
acessar o primeiro amigo e a história mais recente dele. Adicione o código destacado
ao seu 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 ouvir e direcionar eventos do usuário.
Camundongo
Vamos começar ouvindo 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, vamos pegar a posição horizontal do mouse ou do 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, sabemos que precisamos navegar até next
. Caso contrário, prev
(anterior).
Teclado
Agora, vamos detectar pressionamentos de teclado. Se a seta para baixo for pressionada, vamos navegar
até 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 exclusiva das histórias e a UX que as tornou famosas. Isso parece complicado, mas acho que, se você analisar linha por linha, vai perceber que é bem fácil de entender.
De início, armazenamos alguns seletores que nos ajudam a decidir se vamos rolar a tela até um amigo ou mostrar/ocultar uma story. Como estamos trabalhando com HTML, vamos consultar a presença de amigos (usuários) ou histórias (story).
Essas variáveis ajudam a responder perguntas como: "Considerando a história x, "próximo" significa passar para outra história do mesmo amigo ou de 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
}
Confira 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 próxima/anterior: mostre essa história
- Se for o último/primeiro story do amigo: mostre um novo amigo
- Se não houver uma história para seguir nessa direção, não faça nada.
- Armazene a nova história atual em
state
Adicione o código destacado à sua 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 Ver app e depois Tela cheia
.
Conclusão
Isso é tudo que eu precisava saber sobre o componente. Fique à vontade para desenvolver, usar dados e personalizar!