Como construir um componente de abas
Uma visão geral básica de como construir um componente de abas semelhante aos encontrados em aplicativos iOS e Android.
Nesta postagem, quero compartilhar ideias sobre a construção de um componente de abas para a web que seja responsivo, ofereça suporte a variadas entradas de dispositivo e funcione em múltiplos navegadores. Experimente a demonstração.
Se você preferir vídeo, aqui está uma versão desta postagem no YouTube:
Visão geral #
As abas são um componente comum dos sistemas de design, mas podem assumir muitas formas e formatos. Primeiro, havia abas de desktop construídas no elemento <frame>
e agora temos componentes móveis que animam o conteúdo com base em propriedades físicas. Todos estão tentando fazer a mesma coisa: economizar espaço.
Hoje, o essencial da experiência do usuário com abas é uma área de navegação de botão 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 #
Resumindo, achei esse componente muito simples de construir, graças a alguns recursos essenciais da plataforma web:
scroll-snap-points
para interações elegantes de deslizamento e teclado com posições de parada de rolagem apropriadas- Links profundos por meio de hashes de URL para suporte de ancoragem e compartilhamento de rolagem in-page manipulada por navegador
- Suporte para leitor de tela com marcação de elemento
<a>
eid="#hash"
prefers-reduced-motion
para permitir transições crossfade e rolagem instantânea na página- O recurso da web in-draft
@scroll-timeline
para sublinhar dinamicamente e alterar a cor da aba selecionada
O HTML #
Basicamente, a UX aqui consiste em: clique em um link, faça com que a URL represente o estado da página aninhada e, em seguida, veja a atualização da área de conteúdo conforme o navegador rola para o elemento correspondente.
Lá dentro existem alguns membros de conteúdo estrutural: links e :target
s. Precisamos de uma lista de links, para os quais um <nav>
é perfeito, e uma lista de elementos <article>
, para os quais uma <section>
é excelente. Cada hash de link corresponderá a uma seção, permitindo que o navegador role as coisas por meio da ancoragem.
Por exemplo, clicar em um link direciona automaticamente o artigo destino :target
no Chrome 89, sem necessidade de usar JS. O usuário pode então rolar o conteúdo do artigo com seu dispositivo de entrada como sempre. É um conteúdo complementar, conforme indicado na marcação.
Usei a marcação abaixo para organizar as abas:
<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 href
e id
assim:
<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 comprimento variado um conjunto de títulos de imagem. Com conteúdo para trabalhar, podemos começar o layout.
Layouts de rolagem #
Existem 3 tipos diferentes de áreas de rolagem neste componente:
- A navegação (rosa) é rolável horizontalmente
- A área de conteúdo (azul) é rolável horizontalmente
- Cada item de artigo (verde) pode ser rolado verticalmente.

Existem 2 diferentes tipos de elementos envolvidos na rolagem:
- Uma janela
Uma caixa com dimensões definidas que possui a propriedade de estilooverflow
. - Uma superfície superdimensionada
Neste layout, são os containers da lista: links nav, artigos (article) de seção (section) e conteúdo do artigo.
Layout <snap-tabs>
#
O layout de nível superior que escolhi foi flex (Flexbox). Eu defino a direction como column
, de forma que o cabeçalho e a seção são ordenados verticalmente. Esta é a nossa primeira janela de rolagem, e ela esconde tudo com overflow hidden. O cabeçalho (header) e a seção (section) usarão overscroll em breve, como zonas individuais.
HTML
<snap-tabs>
<header></header>
<section></section>
</snap-tabs>
CSS
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 against <section> needing 100% */
flex-shrink: 0;
/* fixes cross browser quarks */
min-block-size: fit-content;
}
}
Apontando de volta ao diagrama colorido de 3 rolagens:
- O
<header>
agora está preparado para ser o container de rolagem (rosa). - O
<section>
está preparado para ser o container de rolagem (azul).
Os frames que destaquei abaixo com VisBug nos ajudam a ver as janelas que foram criadas pelos containers de rolagem.

Layout de cabeçalho de abas <header>
#
O próximo layout é quase o mesmo: eu uso flex para criar ordenação vertical.
HTML
```html/1-4 <snap-tabs> <header> <nav></nav> <span class="snap-indicator"></span> </header> <section></section> </snap-tabs> ```CSSheader {
display: flex;
flex-direction: column;
}
O .snap-indicator
deve viajar horizontalmente junto 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. Acontece que podemos compartilhar os estilos de rolagem entre nossas 2 áreas de rolagem horizontal (header e section), então eu criei uma classe utilitária .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 overflow no eixo x, contenção de rolagem para travar o overscroll, barras de rolagem ocultas para dispositivos de toque e, por último, scroll-snap para bloquear áreas de apresentação de conteúdo. Nossa ordem de tabulação do teclado é acessível e qualquer aba de interação foca naturalmente. Os containers de snap de rolagem também recebem uma bela interação, no estilo carrossel, de seu teclado.
Layout de cabeçalho de abas <nav>
#
Os links nav precisam ser dispostos em uma linha, sem quebras de linha, centralizados verticalmente, e cada item de link deve se encaixar no container scroll-snap. É um trabalho rápido usando o CSS de 2021!
HTML
<nav>
<a></a>
<a></a>
<a></a>
<a></a>
</nav>
Cada link define seu tamanho e estilo, portanto, o layout nav só precisa especificar direção e fluxo. Larguras exclusivas em itens nav deixam a transição entre as abas divertida, já que o indicador ajusta sua largura para o novo destino. Dependendo de quantos elementos estiverem lá, o navegador irá renderizar uma barra de rolagem ou não.

Layout de seção de abas <section>
#
Esta seção é um item flexível e precisa ser o consumidor dominante de espaço. Ele também precisa criar colunas para os artigos serem posicionados. Mais uma vez, um trabalho rápido para CSS de 2021! O block-size: 100%
estica este elemento para preencher o pai tanto quanto for possível, então para seu próprio layout, ele cria uma série de colunas que são 100%
da largura do pai. As porcentagens funcionam muito bem aqui porque definimos fortes restrições para o pai.
HTML
```html/1-4 <section> <article></article> <article></article> <article></article> <article></article> </section> ```CSS display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
}
section {
block-size: 100%;
É como se estivéssemos dizendo "expanda verticalmente o máximo possível, de forma agressiva" (lembre-se do cabeçalho que definimos para flex-shrink: 0
: é uma defesa contra esse impulso de expansão), que define a altura da linha para um conjunto de colunas de altura total. O estilo auto-flow
diz à grade para sempre posicionar os filhos numa linha horizontal, sem quebra automática, exatamente o que queremos; para transbordar a janela pai.

Eu acho isso difícil de entender às vezes! Este elemento de seção cabe em uma caixa, mas também criou um conjunto de caixas. Espero que os recursos visuais e as explicações estejam ajudando.
Layout de abas <article>
#
O usuário deve ser capaz de rolar o conteúdo do artigo, e as barras de rolagem só devem aparecer se houver overflow. Esses elementos do artigo estão numa posição organizada. Eles são simultaneamente um pai de rolagem e um filho de rolagem. O navegador está lidando aqui com algumas interações bem complicadas de toque, mouse e teclado.
HTML
```html <article> <h2></h2> <p></p> <p></p> <h2></h2> <p></p> <p></p> ... </article> ```CSS overflow-y: auto;
overscroll-behavior-y: contain;
}
article {
scroll-snap-align: start;
Eu escolhi fazer com que os artigos se encaixassem no rolador pai. Eu realmente gosto de como os itens do link de navegação e os elementos do artigo se encaixam no início inline de seus respectivos containers de rolagem. Parece um relacionamento harmonioso.

O artigo é um filho da grade e seu tamanho é predeterminado para ser a área da janela de visualização para a qual desejamos fornecer a UX de rolagem. Isto significa que não preciso ter estilos de altura ou largura aqui, só preciso definir como ele transborda. Eu defino overflow-y como auto e, em seguida, também intercepto as interações de rolagem com a propriedade overscroll-behaviour.
Recapitulação de 3 áreas de rolagem #
Abaixo, escolhi nas configurações do meu sistema como "sempre mostrar as barras de rolagem". Acho que é duplamente importante para o layout funcionar com essa configuração ativada, assim como é para mim revisar o layout e a orquestração da rolagem.

Acho que ver a medianiz da barra de rolagem neste componente ajuda a mostrar claramente onde estão as áreas de rolagem, a direção que suportam e como elas interagem umas com as outras. Considere como cada uma desses frames de janela de rolagem também são pais flex ou de grid para um layout.
O DevTools pode nos ajudar a visualizar isso:

Os layouts de rolagem são completos, com snapping, links profundos e acessibilidade por teclado. Uma base sólida para aprimoramentos de UX, estilo e prazer.
Destaque de recursos #
Elementos-filho ajustados com snap de rolagem mantêm sua posição travada durante o redimensionamento. Isto significa que o JavaScript não precisará trazer nada para a viewport ao girar o dispositivo ou redimensionar o navegador. Experimente o Device Mode no Chromium DevTools selecionando qualquer modo diferente de Responsive e redimensione o frame do dispositivo. Observe que o elemento permanece visível e bloqueado com seu conteúdo. Isto está disponível desde que o Chromium atualizou sua implementação para corresponder às especificações. Aqui está uma postagem no blog sobre isto.
Animação #
O objetivo do trabalho de animação aqui é vincular claramente as interações com o feedback da IU. Isto ajuda a orientar ou auxiliar o usuário em sua (esperada) descoberta perfeita de todo o conteúdo. Estarei adicionando movimento com propósito e de forma condicional. Os usuários agora podem especificar suas preferências de movimento em seus sistemas operacionais, e eu irei ter o prazer de responder às suas preferências em minhas interfaces.
Estarei vinculando um sublinhado de aba com a posição de rolagem do artigo. Snapping não é apenas bom alinhamento, mas também serve para ancorar o início e o fim de uma animação. Isto mantém o <nav>
, que atua como um minimapa, conectado ao conteúdo. Verificaremos a preferência de movimento do usuário em CSS e JS.
Comportamento de rolagem #
Há uma oportunidade de aprimorar o comportamento de movimento de ambos :target
e element.scrollIntoView()
. Por default, é instantâneo. O navegador apenas define a posição da rolagem. Bem, e se quisermos fazer a transição para essa posição de rolagem?
@media (prefers-reduced-motion: no-preference) {
.scroll-snap-x {
scroll-behavior: smooth;
}
}
Como estamos introduzindo movimento aqui, e movimento que o usuário não controla (como a rolagem), só aplicamos esse estilo se o usuário não tiver preferência em seu sistema operacional em relação a movimento reduzido. Dessa forma, apresentamos o movimento de rolagem apenas para as pessoas que concordarem com ele.
Indicador de abas #
O objetivo desta animação é ajudar a associar o indicador ao estado do conteúdo. Decidi fazer um crossfade colorido em estilos border-bottom
para usuários que preferem movimento reduzido e um deslizamento vinculado à rolagem + animação de desbotamento de cor para usuários que gostam de movimento.
No Chromium Devtools, posso alternar a preferência e demonstrar os 2 estilos de transição diferentes. Eu me diverti muito construindo 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 escondo o .snap-indicator
quando o usuário prefere movimento reduzido, já que não preciso mais dele. Então eu o substituo por estilos border-block-end
e uma transition
. Observe também na interação das abas que o nav ativo não tem apenas um destaque de sublinhado da marca, mas a cor do texto também é mais escura. O elemento ativo tem um contraste de cor de texto mais alto e um realce de sublinhado brilhante.
Apenas algumas linhas extras de CSS farão alguém se sentir visto (no sentido de que estamos respeitando cuidadosamente suas preferências de movimento). Eu adoro.
@scroll-timeline
#
Na seção acima, mostrei como lido com os estilos de crossfade de movimento reduzido e, nesta seção, mostrarei como vinculei o indicador e uma área de rolagem. Este é um material experimental divertido que mostrarei a seguir. 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 no JavaScript. Se o resultado for false
, o que significa que o usuário prefere movimento reduzido, não executaremos nenhum dos efeitos de movimento do link de rolagem.
if (motionOK) {
// motion based animation code
}
No momento em que este artigo foi escrito, o suporte de navegadores para @scroll-timeline
era zero. É uma especificação draft apenas com implementações experimentais, mas tem um polyfill que uso nesta demonstração.
ScrollTimeline
#
Embora CSS e JavaScript possam criar timelines de rolagem, optei pelo JavaScript para poder usar medições de elementos ao vivo durante a 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 e, ao criar um ScrollTimeline
, eu defino o condutor do link de rolagem, que é o scrollSource
. Normalmente, uma animação na web é executada num intervalo de tempo global, mas com uma sectionScrollTimeline
personalizada na memória. Eu posso mudar tudo isso.
tabindicator.animate({
transform: ...,
width: ...,
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
Antes de entrar nos keyframes da animação, acho importante destacar que o seguidor da rolagem, o tabindicator
, será animado com base numa timeline personalizada, a rolagem da nossa seção. Isto completa a ligação, mas está faltando o ingrediente final: pontos com estado fixo para criar a animação entre eles, também conhecidos como keyframes.
Keyframes dinâmicos #
Existe uma maneira CSS declarativa e pura bem poderosa para animar com @scroll-timeline
, mas a animação que escolhi era dinâmica demais. Não há como fazer a transição entre auto
width e não há como criar dinamicamente uma série de keyframes com base no comprimento dos elementos-filho.
Mas JavaScript sabe como obter essas informações, portanto vamos iterar sobre os elementos filho nós mesmos e obter os valores calculados em 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 utilize como um valor translateX
. Isto cria 4 keyframes transform para a animação. O mesmo é feito para width. É perguntado a cada um qual sua largura dinâmica e então o valor recebido é usado como o valor do keyframe.
Aqui está um exemplo de saída, com base nas minhas fontes e preferências do navegador:
Keyframes 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)"]
Keyframes de width:
[...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 da aba agora será animado em 4 keyframes, dependendo da posição do snap de rolagem do scroller da seção. Os pontos de ajuste criam um delineamento definido entre nossos keyframes e aumentam a sensação de sincronização da animação.

O usuário conduz a animação com sua interação, vendo a largura e a posição do indicador mudar de uma seção para a seguinte, acompanhando perfeitamente com a rolagem.
Você pode não ter notado, mas estou muito orgulhoso da transição de cores quando o item de navegação destacado é selecionado.
O cinza mais claro não selecionado aparece ainda mais empurrado para trás quando o item destacado tem mais contraste. É comum fazer a transição de cor para o texto, como ao passar o mouse e quando selecionado, mas o próximo nível faz a transição dessa cor na rolagem, sincronizada com o indicador de sublinhado.
Veja como eu fiz:
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 nav de aba precisa dessa nova animação colorida, rastreando a mesma timeline de rolagem que o indicador sublinhado. Eu uso a mesma timeline de antes: já que sua função é emitir um tique na rolagem, podemos usar esse tique em qualquer tipo de animação que quisermos. Como fiz antes, crio 4 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, caso contrário, é uma cor de texto padrão. O loop aninhado ali o torna relativamente simples, pois o loop externo é cada item de nav e o loop interno são os keyframes pessoais de cada item nav. Eu verifico se o elemento de loop externo é o mesmo que o de loop interno e uso essa informação para saber quando ele está selecionado.
Eu me diverti muito escrevendo isso. Muito.
Ainda mais melhorias de JavaScript #
Vale lembrar que a essência do que estou mostrando aqui funciona sem JavaScript. Dito isso, vamos ver como podemos melhorá-lo quando JS estiver disponível.
Links profundos #
Links profundos (deep links) são mais um termo relacionado a dispositivos móveis, mas acho que a intenção do link profundo é encontrada aqui com as abas no sentido de que você pode compartilhar uma URL diretamente no conteúdo de uma aba. O navegador navegará internamente na página até o ID que corresponde ao hash da URL. Descobri que este handler de onload
que obtém esse efeito em qualquer plataforma.
window.onload = () => {
if (location.hash) {
tabsection.scrollLeft = document
.querySelector(location.hash)
.offsetLeft;
}
}
Sincronização do final da rolagem #
Nossos usuários nem sempre estão clicando ou usando um teclado, às vezes eles estão apenas rolando livremente, como deveriam ser capazes de fazer. Quando o scroller de seção para de rolar, o local onde ele para precisa ser correspondido na barra de navegação superior.
É assim que espero o final da rolagem:
tabsection.addEventListener('scroll', () => {
clearTimeout(tabsection.scrollEndTimer);
tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100);
});
Sempre que seções estiverem sendo roladas, limpe o timeout da seção, se houver um, e inicie um novo. Quando as seções param de ser roladas, não limpe o timeout e dispare 100 ms após depois de parar. Ao disparar, chame a função que busca descobrir onde o usuário parou.
const determineActiveTabSection = () => {
const i = tabsection.scrollLeft / tabsection.clientWidth;
const matchingNavItem = tabnavitems[i];
matchingNavItem && setActiveTab(matchingNavItem);
};
Assumindo que a rolagem parou em posição ajustada (snapped), dividir a posição atual da rolagem pela largura da área de rolagem deve resultar num número inteiro e não em uma fração. Então, tento pegar um item nav do nosso cache através desse índice calculado e, se encontrar algo, envio a aba correspondente para que seja marcada como ativa.
const setActiveTab = tabbtn => {
tabnav
.querySelector(':scope a[active]')
.removeAttribute('active');
tabbtn.setAttribute('active', '');
tabbtn.scrollIntoView();
};
A definição da aba ativa começa ao limpar qualquer aba que esteja atualmente ativa e, em seguida, dando ao novo item nav atributo de estado "active". A chamada para scrollIntoView()
tem uma interação divertida com CSS que vale a pena observar.
.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 snap de rolagem horizontal, nós aninhamos uma consulta de mídia (media query) que aplica rolagem suave (smooth
) se o usuário for tolerante a movimentos. O JavaScript pode fazer chamadas livremente para rolar os elementos para dentro da janela de visualização e o CSS pode gerenciar a UX de forma declarativa.
Conclusão #
Agora que você sabe como eu fiz, como você faria?! Isto deixa a arquitetura de componentes bem divertida! Quem vai fazer a 1ª versão com slots no seu framework favorito? 🙂
Vamos diversificar nossas abordagens e aprender todas as maneiras de desenvolver na Web. Crie um Glitch e envie um tweet com sua versão para que ela seja adicionada à seção de Remixes da comunidade abaixo.
Remixes da comunidade #
- @devnook, @rob_dodson e @DasSurma com componentes Web: artigo.
- @jhvanderschee com botões: Codepen.