Este codelab ensina como criar uma experiência como os Stories do Instagram na Web. Criaremos o componente conforme avançamos, começando com HTML, depois CSS, e, em seguida, JavaScript.
Confira a postagem do blog Como criar um componente de Histórias para saber mais sobre os aprimoramentos progressivos feitos ao criar esse componente.
Configuração
- Clique em Remixar para editar para tornar o projeto editável.
- Abra
app/index.html
.
HTML
Meu objetivo sempre é usar o HTML semântico.
Como cada amigo pode ter inúmeras histórias, achei que seria significativo usar uma
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
de matérias.
Adicione um elemento <div>
ao seu <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 de histórias. - O atributo
style
em cada<article>
faz parte do carregamento de um marcador de posição sobre o qual você aprenderá mais na próxima seção.
CSS
Nosso conteúdo está pronto para o estilo. Vamos transformar esses ossos em algo que as pessoas vão querem interagir. Trabalharemos com foco em dispositivos móveis hoje.
.stories
Para o contêiner <div class="stories">
, queremos um contêiner de rolagem horizontal.
Podemos fazer isso:
- Transformar o contêiner em uma grade
- Configurar cada filho para preencher a faixa de linha
- Tornar a largura de cada filho a largura de uma janela de visualização de dispositivo móvel
A grade vai continuar posicionando as novas colunas de largura 100vw
à direita da anterior
um, até colocar todos os elementos HTML em sua marcação.
![Chrome e DevTools são abertos com um visual em grade mostrando o layout em largura total](https://web.dev/static/articles/codelab-building-a-stories-component/image/chrome-devtools-open-a-72aaa3c91f873.png?authuser=6&hl=pt)
Adicione o seguinte CSS à parte inferior de app/css/index.css
:
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
}
Agora que o conteúdo se estende além da janela de visualização, é hora de dizer que
contêiner sobre como lidar com isso. 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 página, queremos que o componente descanse suavemente sobre a próxima história,
então usaremos scroll-snap-type: x mandatory
. Leia mais sobre o assunto
CSS nos pontos de ajuste de rolagem do CSS
e overscroll-behavior
seções de minha postagem do blog.
É preciso que o contêiner pai e os filhos concordem com o ajuste de rolagem. Portanto,
vamos lidar com 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
está ativado e desativado. Quando ativados, cada tamanho
rolar para a próxima matéria. Quando desativado, o navegador usa seu
comportamento de rolagem padrão.
Isso fará com que você role a página entre seus amigos, mas ainda temos um problema com histórias para resolver.
.user
Vamos criar um layout na seção .user
para organizar a história filha
no lugar. Usaremos um truque de empilhamento útil para resolver isso.
Essencialmente, estamos criando uma grade 1x1 onde a linha e a coluna têm a mesma grade
alias 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 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, flutuações ou outras diretivas de layout que tomam um elemento fora do fluxo, ainda estamos no fluxo. Além disso, quase nenhum código, olha isso! Isso é detalhado no vídeo e na postagem do blog em 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 um
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 ordem para que nosso usuário
imagem fica na parte superior e aparecerá automaticamente quando o carregamento for concluído. Para
ativar essa opção, colocaremos nosso URL de imagem em uma propriedade personalizada (--bg
) e a usaremos
no CSS para adicionar 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.
quando o carregamento estiver 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ços vazios na
janela de visualização, pois nossa imagem a preencherá. Definir duas imagens de plano de fundo
nos permite realizar um truque interessante na Web do CSS chamado tombstone de carregamento:
- 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 aparecem durante o carregamento do URL
O CSS substituirá automaticamente o gradiente pela imagem assim que o download da imagem for concluído.
Em seguida, adicionaremos CSS para remover 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 devem ser tratados 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 método
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.
Recebi a função de easing personalizado (cubic-bezier(0.4, 0.0, 1,1)
)
do Easing do Material Design
(role até a seção easing acelerado).
Se você tem interesse, provavelmente notou o pointer-events: none
e estamos inovando. Eu diria que essa é a única
e a desvantagem da solução até o momento. Precisamos disso porque um elemento .seen.story
fica no topo e recebe toques, mesmo que esteja invisível. Ao definir o
pointer-events
para none
, transformamos a história de vidro em uma janela e roubamos
mais interações do usuário. Nada mau para a escolha, não é muito difícil de gerenciar aqui
no nosso CSS agora. Não estamos fazendo malabarismos com z-index
. Estou bem com isso
imóvel.
JavaScript
As interações de um componente de Histórias são bastante simples para o usuário: toque no para a direita para avançar, toque na esquerda para voltar. Coisas simples para os usuários trabalho duro para os desenvolvedores. Mas nós cuidaremos de muitos disso.
Configuraçã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)
Nossa primeira linha de JavaScript captura e armazena uma referência ao código HTML principal raiz do elemento. A próxima linha calcula onde está o meio do nosso elemento, então pode decidir se um toque é para avançar ou retroceder.
Estado
Em seguida, criamos um pequeno objeto com algum estado relevante para nossa lógica. Neste
nesse caso, só estamos interessados na história atual. Em nossa marcação HTML, podemos
acessá-la capturando 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
Temos lógica suficiente agora para começar a detectar eventos de usuários e direcioná-los.
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 acontece e não está em um elemento <article>
, ele é salvo e não faz nada.
Se for um artigo, seguramos a posição horizontal do mouse ou dedo com
clientX
: Ainda não implementamos navigateStories
, mas o argumento de que
ela especifica para onde
precisamos ir. Se essa posição do usuário for
maior que a mediana, sabemos que precisamos navegar até next
. Caso contrário,
prev
(anterior).
Teclado
Agora, vamos ouvir 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 das Histórias
É hora de abordar a lógica de negócios única das histórias e a UX em que elas se desenvolveram é conhecida. Isso parece complicado e grosseiro, mas acho que, se você prestar atenção ela será bastante compreensível.
De antemão, guardamos alguns seletores que nos ajudam a decidir se devemos rolar para uma amigo ou mostrar/ocultar uma história. Como o HTML é onde estamos trabalhando, consultando a presença de amigos (usuários) ou histórias (história).
Essas variáveis nos ajudarão a responder perguntas como “Dada história x, “a seguir” ou passar para outra história com esse mesmo amigo ou com um amigo diferente?" Fiz isso usando a árvore estrutura que construímos, alcançando os pais e seus 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 ou a primeira história do amigo: mostre a um novo amigo
- Se não houver história para ir 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 Ver app. Em seguida, pressione
Tela cheia
Conclusão
Esse é o resumo das necessidades que eu tinha com o componente. Sinta-se à vontade para complementar com dados e, em geral, com a sua cara.