Uma visão geral básica de como criar um componente de guias semelhante aos encontrados em apps iOS e app 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 dispositivo e funcione em vários navegadores. Teste a demonstração.
Se você prefere vídeos, confira a versão deste post no YouTube:
Visão geral
As guias são um componente comum dos sistemas de design, mas podem assumir muitas formas e
formatos. Primeiro, havia guias para computadores criadas no elemento <frame>. Agora, temos
componentes para dispositivos 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, os elementos essenciais de uma experiência do usuário com guias são uma área de navegação de 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 para Web
No geral, achei esse componente bem simples de criar, graças a alguns recursos essenciais da plataforma da Web:
scroll-snap-pointspara interações elegantes de deslizar e teclado com posições de parada de rolagem adequadas- Links diretos por hashes de URL para ancoragem de rolagem in-page processada pelo navegador e suporte ao compartilhamento
- Suporte a leitores de tela com marcação de elementos
<a>eid="#hash" prefers-reduced-motionpara ativar transições de esmaecimento e rolagem instantânea na página- O recurso da Web
@scroll-timelineem rascunho para sublinhar e mudar a cor da guia selecionada de forma dinâmica.
O HTML
Basicamente, a UX aqui é: clicar em um link, fazer com que o URL represente o estado da página aninhada e, em seguida, ver a área de conteúdo ser atualizada conforme o navegador rola até o elemento correspondente.
Há alguns membros de conteúdo estrutural: links e :targets. Precisamos
de uma lista de links, que pode ser criada com um <nav>, e uma lista de elementos
<article>, que pode ser criada com um <section>. Cada hash de link vai corresponder a uma seção,
permitindo que o navegador role as coisas por meio de 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 imagem variados. Com o conteúdo para trabalhar, podemos começar o layout.
Layouts de rolagem
Há três tipos diferentes de áreas de rolagem nesse componente:
- A navegação (rosa) é rolável horizontalmente.
- A área de conteúdo (azul) pode ser rolada horizontalmente.
- Cada item de 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 demais
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 que escolhi foi o 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 overflow hidden. O cabeçalho e a
seção vão usar a rolagem esticada em breve, 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 pergaminhos:
<header>agora 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 nos ajudam a ver as janelas que os contêineres de rolagem criaram.
Layout de guias <header>
O próximo layout é quase igual: uso o flex para criar uma ordem 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 cenário. Não há elementos posicionados de forma absoluta aqui.
Em seguida, os estilos de rolagem. É possível compartilhar os estilos de rolagem
entre as duas áreas de rolagem horizontal (cabeçalho e seção). Por isso, 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 capturar a rolagem esticada, barras de rolagem ocultas para dispositivos touchscreen 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 no estilo carrossel do teclado.
Layout do cabeçalho das guias <nav>
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. Swift work for 2021 CSS!
<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 tamanho próprios, então o layout de navegação só precisa especificar a direção e o fluxo. As larguras exclusivas nos itens de navegação tornam a transição entre as guias divertida, já que o indicador ajusta a largura ao novo destino. Dependendo de quantos elementos estão aqui, o navegador vai renderizar uma barra de rolagem ou não.
Layout de guias <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. Mais uma vez, um trabalho
rápido para o CSS 2021! O block-size: 100% estende esse elemento para preencher o
pai o máximo possível. Em seguida, para o próprio layout, ele cria uma série de
colunas que têm 100% da largura do pai. As porcentagens funcionam muito bem aqui
porque escrevemos restrições fortes no 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 forma agressiva"
(lembre-se do cabeçalho que definimos como flex-shrink: 0: é uma defesa contra esse
impulso de expansão), o que define a altura da linha para um conjunto de colunas de altura total. O estilo
auto-flow instrui a grade a sempre organizar os filhos em uma linha
horizontal, sem quebra de linha, exatamente o que queremos: transbordar a janela pai.
Às vezes, acho difícil entender isso! Esse elemento de seção se encaixa em uma caixa, mas também criou um conjunto de caixas. Espero que as imagens e 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 estouro. Esses elementos do artigo estão em uma posição organizada. Eles são simultaneamente um pai e um filho de rolagem. O navegador está lidando com 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 as matérias se ajustem ao scroller pai. Gostei muito de como os itens de link de navegação e os elementos de artigo se ajustam ao início inline dos respectivos contêineres de rolagem. Parece uma relação harmoniosa.
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 UX de rolagem. Isso significa que não preciso de estilos de altura ou largura aqui, só preciso definir como ele transborda. Defino overflow-y como auto e também intercepto as interações de rolagem com a propriedade útil overscroll-behavior.
Resumo das 3 áreas de rolagem
Abaixo, escolhi "Sempre mostrar barras de rolagem" nas configurações do meu sistema. Acho que é duplamente importante que o layout funcione com essa configuração ativada, assim como é importante que eu revise o layout e a orquestração de rolagem.
Acho que ver a calha da barra de rolagem nesse componente ajuda a mostrar claramente onde estão as áreas de rolagem, a direção que elas aceitam e como elas interagem entre si. Considere como cada um desses frames de janela de rolagem também são pais de flex ou grade para um layout.
O DevTools pode nos ajudar a visualizar isso:
Os layouts de rolagem estão completos: encaixe, links diretos e acessibilidade por teclado. Base sólida para melhorias de UX, estilo e satisfação.
Destaque de recurso
Os filhos com ajuste de rolagem mantêm a posição bloqueada durante o redimensionamento. Isso significa que o JavaScript não precisará exibir nada na rotação do dispositivo ou no redimensionamento do navegador. 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 do blog sobre isso.
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 condicionalmente. Agora os usuários podem especificar as preferências de movimento no sistema operacional, e eu gosto muito de responder às preferências deles nas minhas interfaces.
Vou vincular um sublinhado de guia à posição de rolagem do artigo. A fixação não é
apenas um alinhamento bonito, ela também fixa o início e o 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 no CSS e no JS. Há alguns
lugares excelentes para se considerar.
Comportamento de rolagem
É possível melhorar o comportamento de movimento de :target e
element.scrollIntoView(). Por padrão, ela é instantânea. 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 a rolagem), só aplicamos esse estilo se o usuário não tiver preferência no sistema operacional por movimento reduzido. Dessa forma, só introduzimos 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 de cor border-bottom para usuários
que preferem movimento reduzido e uma animação de rolagem vinculada com esmaecimento de cor
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. Foi muito divertido criar 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;
}
}
Oculto o .snap-indicator quando o usuário prefere movimento reduzido, já que não
preciso mais dele. Em seguida, 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
apenas tem 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 destaque de luz de fundo brilhante.
Apenas algumas linhas extras de CSS vão fazer com que alguém se sinta visto (no sentido de que estamos respeitando as preferências de movimento). Adoro isso.
@scroll-timeline
Na seção acima, mostrei como lidar com os estilos de transição de movimento reduzido. Nesta seção, vou mostrar como vinculei o indicador e uma área de rolagem. A seguir, vamos ver alguns recursos experimentais divertidos. 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 vamos executar nenhum
dos efeitos de movimento de vinculação de rolagem.
if (motionOK) {
// motion based animation code
}
No momento, o suporte do navegador para
@scroll-timeline é nulo. É uma
especificação de rascunho com apenas
implementações experimentais. No entanto, ele tem um polyfill, que eu uso nesta
demonstração.
ScrollTimeline
Embora o CSS e o JavaScript possam criar linhas do tempo de rolagem, optei pelo JavaScript para usar medições de elementos em tempo real 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 um elemento siga a posição de rolagem de outro. Ao criar um
ScrollTimeline, defino o driver do link de rolagem, o scrollSource.
Normalmente, uma animação na Web é executada em um período 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 entrar nos frames-chave da animação, é 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: pontos com estado para animar, também conhecidos como
keyframes.
Frames-chave dinâmicos
Há uma maneira CSS declarativa pura muito poderosa de animar com
@scroll-timeline, mas a animação que escolhi fazer era muito dinâmica. Não há
como fazer a transição entre a largura de auto nem como criar dinamicamente
um número de frames-chave com base no comprimento dos filhos.
No entanto, o JavaScript sabe como conseguir essas informações. Por isso, vamos iterar os filhos e pegar os valores calculados no ambiente 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 um é perguntado sobre qual é a largura dinâmica
e, em seguida, ela é usada como um valor de frame-chave.
Veja um exemplo de saída, com base nas minhas fontes e preferências do navegador:
Frames-chave de 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 será animado em quatro frames-chave, dependendo da posição de ajuste de rolagem do controle de rolagem da seção. Os pontos de ajuste criam uma delimitação clara entre os frames-chave e realmente contribuem para a sensação de sincronização 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 outra, 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 mais claro não selecionado parece ainda mais recuado quando o item destacado tem mais contraste. É comum fazer a transição de cores para texto, como ao passar o cursor e quando selecionado, mas é um nível acima 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 de guia precisa dessa nova animação de cor, que segue a mesma linha do tempo de rolagem do indicador de sublinhado. Uso a mesma linha do tempo de antes: como a função dela é emitir um tick na rolagem, podemos usar esse tick em qualquer tipo de animação que quisermos. Como fiz antes, crio quatro frames-chave no loop e retorno as 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 frame-chave com a cor var(--text-active-color) destaca o link, e
a cor do 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
keyframes pessoais do item de navegação. Verifico se o elemento do loop externo é igual ao
do loop interno e uso isso para saber quando ele é selecionado.
Foi muito divertido escrever 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 melhorá-lo quando o JS estiver disponível.
Links diretos
Os links diretos são um termo mais voltado para dispositivos móveis, mas acho que a intenção deles é
atendida aqui com as guias, já que é possível compartilhar um URL diretamente para o conteúdo de uma guia. O
navegador vai navegar na página até o ID que corresponde ao hash do URL. Descobri que
esse gerenciador onload produziu o efeito em todas as plataformas.
window.onload = () => {
if (location.hash) {
tabsection.scrollLeft = document
.querySelector(location.hash)
.offsetLeft;
}
}
Sincronização de fim de rolagem
Nossos usuários nem sempre estão clicando ou usando um teclado. Às vezes, eles estão apenas rolando livremente, como deveria ser possível. Quando o rolador de seção parar de rolar, o local em que ele parar 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 dispare 100 ms após o repouso. Quando ele for acionado, 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);
};
Supondo que o ajuste de rolagem tenha sido feito, dividir a posição de rolagem atual pela largura da área de rolagem deve resultar em um número inteiro, e não em um decimal. Em seguida, tento pegar 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, em seguida, atribua
o atributo de estado ativo ao item de navegação recebido. A chamada para scrollIntoView()
tem uma interação divertida com o 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 for tolerante a movimento. O JavaScript pode fazer chamadas livremente para rolar elementos para a visualização, e o CSS pode gerenciar a UX de forma declarativa.
Às vezes, eles formam uma combinação bem interessante.
Conclusão
Agora que você sabe como eu fiz, como você faria?! Isso torna a arquitetura de componentes bem 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, 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.