Uma visão geral fundamental de como criar um componente de guias semelhante aos encontrados em apps iOS e Android.
Nesta postagem, quero compartilhar ideias sobre como criar um componente de guias para a Web que seja responsivo, ofereça suporte a várias entradas de dispositivos e funcione em todos os navegadores. Teste a demonstração.
Se preferir vídeo, confira uma versão desta postagem no YouTube:
Visão geral
As guias são um componente comum dos sistemas de design, mas podem ter muitas formas e formatos. Primeiro, havia guias de computador criadas no elemento <frame>
, e agora temos
componentes móveis suaves que animam o conteúdo com base em propriedades físicas.
Todos eles tentam fazer a mesma coisa: economizar espaço.
Hoje, o essencial de uma experiência do usuário com guias é uma área de navegação por botões que alterna a visibilidade do conteúdo em um frame de exibição. Muitas áreas de conteúdo diferentes compartilham o mesmo espaço, mas são apresentadas condicionalmente com base no botão selecionado na navegação.

Táticas da Web
No geral, achei esse componente bem simples de criar, graças a alguns recursos críticos da plataforma da Web:
scroll-snap-points
para interações elegantes de deslizar e teclado com posições de parada de rolagem adequadas- Links diretos via hashes de URL para ancoragem de rolagem na página processada pelo navegador e suporte ao compartilhamento
- Suporte a leitores de tela com marcação de elementos
<a>
eid="#hash"
prefers-reduced-motion
para ativar transições de crossfade e rolagem instantânea na página.- O recurso da Web
@scroll-timeline
em rascunho para sublinhar dinamicamente e mudar a cor da guia selecionada
O HTML
Basicamente, a UX aqui é: clique em um link, faça com que o URL represente o estado da página aninhada e veja a área de conteúdo ser atualizada à medida que o navegador rola até o elemento correspondente.
Há alguns membros de conteúdo estrutural: links e :target
s. Precisamos de uma lista de links, que um <nav>
é ótimo para isso, e uma lista de elementos <article>
, que um <section>
é ótimo para isso. Cada hash de link corresponde a uma seção, permitindo que o navegador role as coisas por ancoragem.
Por exemplo, clicar em um link foca automaticamente o artigo :target
no
Chrome 89, sem necessidade de JS. O usuário pode rolar o conteúdo do artigo com o dispositivo de entrada como sempre. É um conteúdo complementar, conforme indicado na
marcação.
Usei a seguinte marcação para organizar as guias:
<snap-tabs>
<header>
<nav>
<a></a>
<a></a>
<a></a>
<a></a>
</nav>
</header>
<section>
<article></article>
<article></article>
<article></article>
<article></article>
</section>
</snap-tabs>
Posso estabelecer conexões entre os elementos <a>
e <article>
com as propriedades href
e id
desta forma:
<snap-tabs>
<header>
<nav>
<a href="#responsive"></a>
<a href="#accessible"></a>
<a href="#overscroll"></a>
<a href="#more"></a>
</nav>
</header>
<section>
<article id="responsive"></article>
<article id="accessible"></article>
<article id="overscroll"></article>
<article id="more"></article>
</section>
</snap-tabs>
Em seguida, preenchi os artigos com quantidades variadas de lorem e os links com um conjunto de títulos de comprimento e imagens variados. Com o conteúdo para trabalhar, podemos começar o layout.
Layouts roláveis
Há três tipos diferentes de áreas de rolagem neste componente:
- A navegação (rosa) pode ser rolada horizontalmente
- A área de conteúdo (azul) pode ser rolada horizontalmente
- Cada item do artigo (verde) pode ser rolado verticalmente.

Há dois tipos diferentes de elementos envolvidos na rolagem:
- Uma janela
Uma caixa com dimensões definidas que tem o estilo de propriedadeoverflow
. - Uma superfície grande
Nesse layout, são os contêineres de lista: links de navegação, artigos de seção e conteúdo de artigos.
Layout do <snap-tabs>
O layout de nível superior escolhido foi flex (Flexbox). Defini a direção como
column
, para que o cabeçalho e a seção sejam ordenados verticalmente. Esta é nossa primeira
janela de rolagem, e ela oculta tudo com o estouro oculto. Em breve, o cabeçalho e a seção vão usar o overscroll como zonas individuais.
<snap-tabs> <header></header> <section></section> </snap-tabs>
snap-tabs { display: flex; flex-direction: column; /* establish primary containing box */ overflow: hidden; position: relative; & > section { /* be pushy about consuming all space */ block-size: 100%; } & > header { /* defend againstneeding 100% */ flex-shrink: 0; /* fixes cross browser quarks */ min-block-size: fit-content; } }
Voltando ao diagrama colorido de três rolagens:
<header>
está preparado para ser o contêiner de rolagem (rosa).<section>
está preparado para ser o contêiner de rolagem (azul).
Os frames que destaquei abaixo com o VisBug ajudam a ver as janelas que os contêineres de rolagem criaram.

Layout de guias <header>
O próximo layout é quase o mesmo: uso o flex para criar uma ordenação vertical.
<snap-tabs> <header> <nav></nav> <span class="snap-indicator"></span> </header> <section></section> </snap-tabs>
header { display: flex; flex-direction: column; }
O .snap-indicator
precisa se mover horizontalmente com o grupo de links, e
esse layout de cabeçalho ajuda a definir esse estágio. Nenhum elemento posicionado de forma absoluta aqui!

Em seguida, os estilos de rolagem. Descobri que podemos compartilhar os estilos de rolagem
entre as duas áreas de rolagem horizontal (cabeçalho e seção). Por isso, criei uma classe
de utilidade, .scroll-snap-x
.
.scroll-snap-x {
/* browser decide if x is ok to scroll and show bars on, y hidden */
overflow: auto hidden;
/* prevent scroll chaining on x scroll */
overscroll-behavior-x: contain;
/* scrolling should snap children on x */
scroll-snap-type: x mandatory;
@media (hover: none) {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
}
Cada um precisa de estouro no eixo x, contenção de rolagem para evitar o excesso de rolagem, barras de rolagem ocultas para dispositivos touch e, por fim, ajuste de rolagem para bloquear áreas de apresentação de conteúdo. A ordem de tabulação do teclado é acessível, e todas as interações guiam o foco naturalmente. Os contêineres de ajuste de rolagem também recebem uma interação de estilo de carrossel agradável do teclado.
Layout do cabeçalho <nav>
das guias
Os links de navegação precisam ser dispostos em uma linha, sem quebras de linha, centralizados verticalmente, e cada item de link precisa se ajustar ao contêiner de ajuste de rolagem. Trabalho rápido para CSS de 2021!
<nav> <a></a> <a></a> <a></a> <a></a> </nav>
nav { display: flex; & a { scroll-snap-align: start; display: inline-flex; align-items: center; white-space: nowrap; } }
Cada link tem um estilo e um tamanho próprios. Portanto, o layout de navegação só precisa especificar direção e fluxo. Larguras exclusivas em itens de navegação tornam a transição entre guias divertida, já que o indicador ajusta a largura para o novo destino. Dependendo de quantos elementos estão aqui, o navegador vai renderizar ou não uma barra de rolagem.

Layout de guias <section>
Essa seção é um item flexível e precisa ser o consumidor dominante de espaço. Também é necessário criar colunas para os artigos. Mais uma vez, um trabalho
rápido para o CSS 2021! O block-size: 100%
estica esse elemento para preencher o
elemento pai o máximo possível. Depois, para o próprio layout, ele cria uma série de
colunas que são 100%
a largura do elemento pai. As porcentagens funcionam muito bem aqui porque escrevemos restrições fortes no elemento pai.
<section> <article></article> <article></article> <article></article> <article></article> </section>
section { block-size: 100%; display: grid; grid-auto-flow: column; grid-auto-columns: 100%; }
É como se estivéssemos dizendo "expanda verticalmente o máximo possível, de maneira insistente" (lembre-se do cabeçalho que definimos como flex-shrink: 0
: é uma defesa contra essa expansão insistente), o que define a altura da linha para um conjunto de colunas de altura total. O estilo
auto-flow
diz à grade para sempre dispor os filhos em uma linha
horizontal, sem quebra de linha, exatamente o que queremos: transbordar a janela principal.

Às vezes, acho difícil entender isso! Esse elemento de seção está se encaixando em uma caixa, mas também criou um conjunto de caixas. Espero que os recursos visuais e as explicações estejam ajudando.
Layout de guias <article>
O usuário precisa conseguir rolar o conteúdo do artigo, e as barras de rolagem só devem aparecer se houver um estouro. Esses elementos do artigo estão em uma posição organizada. Eles são simultaneamente um elemento pai e um elemento filho de rolagem. O navegador está processando algumas interações complicadas de toque, mouse e teclado para nós aqui.
<article> <h2></h2> <p></p> <p></p> <h2></h2> <p></p> <p></p> ... </article>
article { scroll-snap-align: start; overflow-y: auto; overscroll-behavior-y: contain; }
Escolhi fazer com que os artigos se encaixassem no scroller principal. Eu gosto muito de como os itens de link de navegação e os elementos do artigo se ajustam ao início da linha dos respectivos contêineres de rolagem. Parece e soa como um relacionamento harmonioso.

O artigo é um filho da grade, e o tamanho dele é predeterminado para ser a área da janela de visualização em que queremos fornecer a experiência de rolagem. Isso significa que não preciso de estilos de altura ou largura aqui, apenas definir como ele transborda. Defini overflow-y como "auto" e também capturei as interações de rolagem com a propriedade útil overscroll-behavior.
Resumo das três áreas de rolagem
Abaixo, escolhi nas configurações do sistema "sempre mostrar barras de rolagem". Acho que é duplamente importante que o layout funcione com essa configuração ativada, assim como é importante para mim analisar o layout e a organização da rolagem.

Acho que ver o encadeamento da barra de rolagem nesse componente ajuda a mostrar claramente onde estão as áreas de rolagem, a direção que elas oferecem suporte e como interagem entre si. Considere como cada um desses frames de janela de rolagem também é um elemento flex ou pai de grade para um layout.
O DevTools pode ajudar a visualizar isso:

Os layouts de rolagem estão completos: ajuste, link direto e acessibilidade do teclado. Base sólida para melhorias de UX, estilo e satisfação.
Destaque de recurso
Os filhos ajustados por rolagem mantêm a posição bloqueada durante o redimensionamento. Isso significa que o JavaScript não precisará mostrar nada quando o dispositivo for girado ou o navegador for redimensionado. Teste no Modo dispositivo do Chromium DevTools. Para isso, selecione qualquer modo que não seja Responsivo e redimensione o frame do dispositivo. Observe que o elemento permanece visível e bloqueado com o conteúdo. Isso está disponível desde que o Chromium atualizou a implementação para corresponder à especificação. Confira uma postagem no blog sobre o assunto.
Animação
O objetivo do trabalho de animação aqui é vincular claramente as interações ao feedback da interface. Isso ajuda a orientar ou auxiliar o usuário na descoberta (esperamos que) perfeita de todo o conteúdo. Vou adicionar movimento com propósito e de forma condicional. Agora os usuários podem especificar as preferências de movimento no sistema operacional, e eu gosto muito de responder a essas preferências nas minhas interfaces.
Vou vincular um sublinhado de guia à posição de rolagem do artigo. O ajuste não é apenas um alinhamento bonito, mas também a ancoragem do início e do fim de uma animação.
Isso mantém o <nav>
, que funciona como um minimapa, conectado ao conteúdo.
Vamos verificar a preferência de movimento do usuário em CSS e JS. Há alguns lugares ótimos para ser atencioso!
Comportamento de rolagem
Há uma oportunidade de melhorar o comportamento de movimento de :target
e element.scrollIntoView()
. Por padrão, é instantâneo. O navegador apenas define a posição de rolagem. E se quisermos fazer a transição para essa posição de rolagem em vez de piscar?
@media (prefers-reduced-motion: no-preference) {
.scroll-snap-x {
scroll-behavior: smooth;
}
}
Como estamos introduzindo movimento aqui, e um movimento que o usuário não controla (como rolagem), só aplicamos esse estilo se o usuário não tiver preferência no sistema operacional em relação à redução de movimento. Assim, só apresentamos o movimento de rolagem para quem não se importa com isso.
Indicador de guias
O objetivo dessa animação é ajudar a associar o indicador ao estado do conteúdo. Decidi usar estilos de transição gradual de cores border-bottom
para usuários
que preferem movimento reduzido e uma animação de deslizamento vinculada à rolagem + transição gradual de cores
para usuários que não se importam com movimento.
No Chromium Devtools, posso alternar a preferência e demonstrar os dois estilos de transição diferentes. Me diverti muito criando isso.
@media (prefers-reduced-motion: reduce) {
snap-tabs > header a {
border-block-end: var(--indicator-size) solid hsl(var(--accent) / 0%);
transition: color .7s ease, border-color .5s ease;
&:is(:target,:active,[active]) {
color: var(--text-active-color);
border-block-end-color: hsl(var(--accent));
}
}
snap-tabs .snap-indicator {
visibility: hidden;
}
}
Eu oculto o .snap-indicator
quando o usuário prefere movimento reduzido, já que não preciso mais dele. Depois, substituo por estilos border-block-end
e um
transition
. Observe também na interação das guias que o item de navegação ativo não
tem apenas um destaque de sublinhado da marca, mas também uma cor de texto mais escura. O
elemento ativo tem um contraste de cor de texto maior e um destaque de luz brilhante.
Apenas algumas linhas extras de CSS farão com que alguém se sinta visto (no sentido de que estamos respeitando cuidadosamente as preferências de movimento). Adoro isso.
@scroll-timeline
Na seção acima, mostrei como processar os estilos de transição reduzida, e nesta seção vou mostrar como vinculei o indicador e uma área de rolagem. Em seguida, vamos mostrar algumas coisas experimentais e divertidas. Espero que você esteja tão animado quanto eu.
const { matches:motionOK } = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
Primeiro, verifico a preferência de movimento do usuário em JavaScript. Se o resultado for false
, ou seja, o usuário prefere movimento reduzido, não vamos executar nenhum dos efeitos de movimento de vinculação de rolagem.
if (motionOK) {
// motion based animation code
}
No momento da redação deste artigo, não há suporte para @scroll-timeline
em navegadores. É uma especificação de rascunho com apenas implementações experimentais. No entanto, ele tem um polyfill, que eu uso nesta
demonstração.
ScrollTimeline
Embora CSS e JavaScript possam criar linhas do tempo de rolagem, optei pelo JavaScript para usar medições de elementos ativos na animação.
const sectionScrollTimeline = new ScrollTimeline({
scrollSource: tabsection, // snap-tabs > section
orientation: 'inline', // scroll in the direction letters flow
fill: 'both', // bi-directional linking
});
Quero que uma coisa siga a posição de rolagem de outra. Ao criar um
ScrollTimeline
, defino o driver do link de rolagem, o scrollSource
.
Normalmente, uma animação na Web é executada em relação a uma marca de tempo global, mas com um
sectionScrollTimeline
personalizado na memória, posso mudar tudo isso.
tabindicator.animate({
transform: ...,
width: ...,
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
Antes de falar sobre os frames-chave da animação, acho importante
destacar que o seguidor da rolagem, tabindicator
, será animado com base
em uma linha do tempo personalizada, a rolagem da nossa seção. Isso conclui a vinculação, mas falta o ingrediente final, que são os pontos com estado para animar, também conhecidos como quadros-chave.
Frames-chave dinâmicos
Há uma maneira muito eficiente de animar com CSS declarativo puro usando
@scroll-timeline
, mas a animação que escolhi fazer era muito dinâmica. Não há como fazer a transição entre a largura auto
nem criar dinamicamente um número de frames-chave com base no comprimento dos elementos filhos.
No entanto, o JavaScript sabe como extrair essas informações. Por isso, vamos iterar sobre os filhos e extrair os valores calculados no tempo de execução:
tabindicator.animate({
transform: [...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`),
width: [...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
Para cada tabnavitem
, desestruture a posição offsetLeft
e retorne uma string
que a use como um valor translateX
. Isso cria quatro frames-chave de transformação para a animação. O mesmo é feito para a largura. Cada elemento pergunta qual é a largura dinâmica e, em seguida, ela é usada como um valor de keyframe.
Confira um exemplo de saída com base nas minhas fontes e preferências do navegador:
Frames-chave TranslateX:
[...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`)
// results in 4 array items, which represent 4 keyframe states
// ["translateX(0px)", "translateX(121px)", "translateX(238px)", "translateX(464px)"]
Frames-chave de largura:
[...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
// results in 4 array items, which represent 4 keyframe states
// ["121px", "117px", "226px", "67px"]
Para resumir a estratégia, o indicador de guia agora vai animar em quatro keyframes dependendo da posição de ajuste de rolagem do scroller da seção. Os pontos de ajuste criam uma delimitação clara entre os frames-chave e contribuem para a sensação sincronizada da animação.

O usuário controla a animação com a interação, vendo a largura e a posição do indicador mudarem de uma seção para a próxima, acompanhando perfeitamente a rolagem.
Talvez você não tenha percebido, mas tenho muito orgulho da transição de cor quando o item de navegação destacado é selecionado.
O cinza claro não selecionado parece ainda mais recuado quando o item destacado tem mais contraste. É comum fazer a transição da cor do texto, como ao passar o cursor e quando selecionado, mas é um nível superior fazer a transição dessa cor ao rolar, sincronizada com o indicador de sublinhado.
Foi assim que eu fiz isso:
tabnavitems.forEach(navitem => {
navitem.animate({
color: [...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
});
Cada link de navegação por guias precisa dessa nova animação de cor, acompanhando a mesma linha do tempo de rolagem que o indicador de sublinhado. Uso a mesma linha do tempo de antes: como a função dela é emitir um tique ao rolar, podemos usar esse tique em qualquer tipo de animação que quisermos. Como fiz antes, crio quatro keyframes no loop e retorno cores.
[...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
// results in 4 array items, which represent 4 keyframe states
// [
"var(--text-active-color)",
"var(--text-color)",
"var(--text-color)",
"var(--text-color)",
]
O keyframe com a cor var(--text-active-color)
destaca o link, e
ele é uma cor de texto padrão. O loop aninhado torna isso relativamente simples, já que o loop externo é cada item de navegação, e o loop interno é cada keyframe pessoal do item de navegação. Verifico se o elemento do loop externo é o mesmo do loop interno e uso isso para saber quando ele está selecionado.
Me diverti muito escrevendo isso. Demais.
Ainda mais melhorias no JavaScript
Vale lembrar que o núcleo do que estou mostrando aqui funciona sem JavaScript. Dito isso, vamos ver como podemos melhorar quando o JS está disponível.
Links diretos
Os links diretos são mais um termo para dispositivos móveis, mas acho que a intenção deles é
atendida aqui com as guias, já que você pode compartilhar um URL diretamente com o conteúdo de uma guia. O navegador vai navegar na página até o ID correspondente no hash do URL. Encontrei
este manipulador onload
que fez o efeito em todas as plataformas.
window.onload = () => {
if (location.hash) {
tabsection.scrollLeft = document
.querySelector(location.hash)
.offsetLeft;
}
}
Sincronização do fim da rolagem
Nossos usuários nem sempre clicam ou usam um teclado. Às vezes, eles apenas rolam a tela livremente, como deveriam poder fazer. Quando o scroller de seção para de rolar, o local em que ele para precisa ser correspondido na barra de navegação superior.
Veja como aguardo o fim da rolagem:
js
tabsection.addEventListener('scroll', () => {
clearTimeout(tabsection.scrollEndTimer);
tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100);
});
Sempre que as seções forem roladas, limpe o tempo limite da seção, se houver, e inicie um novo. Quando as seções pararem de ser roladas, não limpe o tempo limite e acione 100 ms após o descanso. Quando ele for disparado, chame a função que tenta descobrir onde o usuário parou.
const determineActiveTabSection = () => {
const i = tabsection.scrollLeft / tabsection.clientWidth;
const matchingNavItem = tabnavitems[i];
matchingNavItem && setActiveTab(matchingNavItem);
};
Supondo que a rolagem tenha sido ajustada, dividir a posição atual da rolagem pela largura da área de rolagem deve resultar em um número inteiro, não um decimal. Em seguida, tento extrair um navitem do nosso cache usando esse índice calculado e, se ele encontrar algo, envio a correspondência para ser definida como ativa.
const setActiveTab = tabbtn => {
tabnav
.querySelector(':scope a[active]')
.removeAttribute('active');
tabbtn.setAttribute('active', '');
tabbtn.scrollIntoView();
};
Para definir a guia ativa, primeiro limpe qualquer guia ativa no momento e atribua o estado ativo ao item de navegação recebido. A chamada para scrollIntoView()
tem uma interação divertida com CSS que vale a pena notar.
.scroll-snap-x {
overflow: auto hidden;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
}
No CSS do utilitário de ajuste de rolagem horizontal, aninhamos uma consulta de mídia que aplica a rolagem smooth
se o usuário não tiver restrições de movimento. O JavaScript pode fazer chamadas livremente para rolar elementos até a visualização, e o CSS pode gerenciar a UX de forma declarativa.
Às vezes, eles formam um casal encantador.
Conclusão
Agora que você sabe como eu fiz isso, como você faria? Isso resulta em uma arquitetura de componentes divertida! Quem vai criar a primeira versão com slots no framework favorito? 🙂
Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web. Crie um Glitch, me envie um tweet com sua versão, e eu vou adicioná-la à seção Remixes da comunidade abaixo.
Remixes da comunidade
- @devnook, @rob_dodson e @DasSurma com componentes da Web: artigo.
- @jhvanderschee com botões: Codepen.