Uma visão geral básica 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 dispositivo e funcione em vários navegadores. Teste a demonstração.
Se preferir vídeo, aqui está uma versão do YouTube desta postagem:
Visão geral
As guias são um componente comum dos sistemas de design, mas podem assumir muitas formas. Primeiro, havia guias para computador criadas no elemento <frame>
. Agora, temos
componentes para dispositivos móveis que animam o conteúdo com base nas propriedades físicas.
Eles estão tentando fazer a mesma coisa: economizar espaço.
Atualmente, o essencial da experiência do usuário nas guias é 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 bastante simples de criar, graças a alguns recursos essenciais da plataforma da Web:
scroll-snap-points
para interações elegantes de deslizar e teclado com as posições de parada de rolagem adequadas.- Links diretos via hashes de URL para compatibilidade com ancoragem e compartilhamento de rolagem na página manipuladas pelo navegador.
- Suporte a leitores de tela com as marcações 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
no rascunho para sublinhar dinamicamente e mudar a cor da guia selecionada.
O HTML
Basicamente, a UX é: clicar em um link, fazer com que o URL represente o estado da página aninhado e, em seguida, conferir a atualização da área do conteúdo à medida que o navegador rola até o elemento correspondente.
Há alguns participantes estruturais nela: links e :target
s. Precisamos
de uma lista de links, em que um <nav>
é ótimo, e de uma lista de elementos <article>
, em que um <section>
é ótimo. Cada hash de link corresponde a uma seção,
permitindo que o navegador role itens por meio da ancoragem.
Por exemplo, clicar em um link foca automaticamente o artigo :target
no Chrome 89, sem a necessidade de JS. Assim, 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>
É possível 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 mistas de lorem e os links com um conjunto misto de títulos de tamanho e imagem. 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) pode ser rolável horizontalmente.
- A área de conteúdo (azul) pode ser rolada horizontalmente.
- Cada item de artigo (verde) pode ser rolável 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 muito grande
Neste layout, são os contêineres de lista: links de navegação, artigos de seção e conteúdo do artigo.
Layout do <snap-tabs>
O layout de nível superior que escolhi foi flexível (Flexbox). Defini a direção como
column
, de modo que o cabeçalho e a seção sejam ordenados verticalmente. Essa é nossa primeira
janela de rolagem, que oculta tudo com o recurso "overflow" oculto. O cabeçalho e a
seção vão empregar 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 rolagem:
<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 destacados abaixo com VisBug nos ajudam a ver as janelas criadas pelos contêineres de rolagem.
Layout das guias <header>
O próximo layout é quase o mesmo: uso flexível para criar 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 viajar horizontalmente com o grupo de links, e
esse layout de cabeçalho ajuda a definir esse cenário. Não há elementos absolutos aqui.
Depois, os estilos de rolagem. Como podemos compartilhar os estilos de rolagem
entre nossas duas áreas de rolagem horizontais (cabeçalho e seção), criei uma classe
de utilitário, .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 elemento precisa transbordar no eixo x, contenção de rolagem para capturar a rolagem esticada, barras de rolagem ocultas para dispositivos com toque e, por último, ajuste de rolagem para bloquear áreas de apresentação de conteúdo. A ordem de tabulação do teclado é acessível, e o foco de todas as interações é natural. Os contêineres de ajuste de rolagem também recebem uma boa interação no estilo carrossel do teclado.
Layout de cabeçalho de 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 ser ajustado 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 define um estilo e tamanho próprios, de modo que o layout de navegação só precisa especificar a direção e o fluxo. Larguras exclusivas em itens de navegação tornam a transição entre guias divertida, já que o indicador ajusta a largura de acordo com o novo destino. Dependendo de quantos elementos houver aqui, o navegador renderizará uma barra de rolagem ou não.
Layout das 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 serem colocados. Mais uma vez, 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%
a 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 "expandir verticalmente o máximo possível, de maneira instrutiva" (lembre-se do cabeçalho que definimos como flex-shrink: 0
: é uma defesa contra esse push de expansão), que define a altura da linha para um conjunto de colunas com altura total. O estilo
auto-flow
instrui a grade a sempre posicionar os filhos em uma linha
horizontal, sem quebra, exatamente o que queremos, para sobrecarregar a janela mãe.
Às vezes é difícil entender isso! Esse elemento de seção se encaixa em uma caixa, mas também criou um conjunto de caixas. Espero que os recursos visuais e as explicações estejam ajudando.
Layout das guias <article>
O usuário precisa conseguir rolar o conteúdo do artigo, e as barras de rolagem só vão aparecer se houver sobrecarga. Esses elementos do artigo estão em uma ótima posição. Eles são simultaneamente um pai e um filho da rolagem. O navegador está realmente lidando com algumas interações complicadas de toque, mouse e teclado.
<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; }
Optei por encaixar os artigos dentro do botão de rolagem principal. Gosto muito da forma como os itens dos links de navegação e os elementos do artigo se ajustam ao início inline dos respectivos contêineres de rolagem. Parece e parece uma relação harmoniosa.
O artigo é uma grade filha, e o tamanho é predeterminado para a área da janela de visualização que queremos oferecer à UX de rolagem. Isso significa que não preciso de estilos de altura ou largura aqui, só preciso definir o transbordamento. Configurei o overflow-y como automático e também intercepte as interações de rolagem com a útil propriedade sobre comportamento de rolagem.
Resumo das três áreas de rolagem
Abaixo, selecionei nas configurações do sistema a opção "Sempre mostrar barras de rolagem". Acho que é duplamente importante que o layout funcione com essa configuração ativada, já que preciso revisar o layout e a orquestração de rolagem.
Acho que a medianiz da barra de rolagem nesse componente ajuda a mostrar claramente onde estão as áreas de rolagem, a direção em que elas oferecem suporte e como elas interagem entre si. Considere como cada um desses frames de janela de rolagem também são pais flexíveis ou de grade para um layout.
O DevTools pode nos ajudar a visualizar isso:
Os layouts de rolagem são completos: com ajuste, link direto e acessível pelo teclado. Base sólida para melhorias de UX, estilo e satisfação.
Destaque do recurso
Os filhos direcionados à rolagem mantêm a posição fixa 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, selecionando qualquer modo diferente de Responsivo e redimensionando 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 esta postagem do blog (link em inglês) 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 (esperançosamente) de todo o conteúdo. Vou adicionar movimento com propósito e condicionalmente. Os usuários agora 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. O ajuste não é apenas
bem alinhamento, mas também ancora o início e o fim de uma animação.
Isso mantém o <nav>
, que funciona como um
minimapa, conectado ao conteúdo.
Verificaremos a preferência de movimento do usuário no CSS e no JS. Há alguns lugares ótimos para ser considerado!
Comportamento de rolagem
Há uma oportunidade de melhorar o comportamento de movimento de :target
e
element.scrollIntoView()
. Por padrão, ele é instantâneo. O navegador apenas define a
posição de rolagem. E se quisermos mudar para essa posição de rolagem,
em vez de piscar nela?
@media (prefers-reduced-motion: no-preference) {
.scroll-snap-x {
scroll-behavior: smooth;
}
}
Como estamos introduzindo o movimento aqui, e o 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 em relação a movimentos reduzidos. Dessa forma, só introduzimos o movimento de rolagem para pessoas que concordam com ele.
Indicador de guias
O objetivo dessa animação é ajudar a associar o indicador ao estado
do conteúdo. Decidi colorir os estilos border-bottom
de fading cruzado para usuários
que preferem movimento reduzido e uma animação de deslizamento e esmaecimento de cor vinculado à rolagem
para usuários que não aprovam movimento.
No Chromium Devtools, posso alternar a preferência e demonstrar os dois estilos de transição diferentes. Eu 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;
}
}
Oculto o .snap-indicator
quando o usuário prefere movimento reduzido, já que não
preciso mais dele. Em seguida, substituí-lo 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
só tem um sublinhado de marca, mas a cor do texto também é mais escura. O
elemento ativo tem maior contraste de cores de texto e um destaque claro de fundo.
Com apenas algumas linhas extras de CSS, as pessoas se sentirão vistas (no sentido de que estamos respeitando cuidadosamente as preferências de movimento delas). Eu adoro.
@scroll-timeline
Na seção acima, mostrei como lidar com os estilos de crossfade de movimento reduzido e nesta seção vou mostrar como vincular o indicador e uma área de rolagem. Estes são alguns recursos experimentais divertidos a seguir. Espero que 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, nenhum
dos efeitos de movimento de vinculação de rolagem será executado.
if (motionOK) {
// motion based animation code
}
Até o momento, a compatibilidade do navegador com @scroll-timeline
ainda não existe. É uma
especificação de rascunho apenas
com implementações experimentais. Mas ele tem um polyfill, que vou usar nesta demonstração.
ScrollTimeline
Embora CSS e JavaScript possam criar linhas do tempo de rolagem, optei pelo JavaScript para usar as medidas de elementos dinâmicos 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 e, ao criar um
ScrollTimeline
, defino o driver do link de rolagem, o scrollSource
.
Normalmente, uma animação na Web é executada com base em uma marcação de 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, acho importante
apontar o seguidor da rolagem, tabindicator
, que será animado com base
em uma linha do tempo personalizada, a rolagem da nossa seção. Isso conclui a vinculação, mas faltam
os ingredientes finais, os pontos de estado entre os quais se deve animar, também conhecidos como
frames-chave.
Frames dinâmicos
Há um CSS puro e muito eficiente de animação com
@scroll-timeline
, mas a animação que escolhi foi muito dinâmica. Não há
como fazer a transição entre a largura de auto
, nem para criar dinamicamente
vários frames-chave com base no comprimento dos filhos.
No entanto, o JavaScript sabe como conseguir essas informações. Por isso, vamos iterar os filhos por conta própria e extrair os valores computados no momento da 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
, desestruturar a posição offsetLeft
e retornar 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 precisa saber qual é a largura dinâmica
e, em seguida, essa informação é usada como um valor de frame-chave.
Este é um exemplo de saída, com base nas minhas preferências de fonte e navegador:
Frames-chave do 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 botão de rolagem da seção. Os pontos de ajuste criam um delineamento claro entre os frames-chave e criam uma sensação sincronizada da animação.
O usuário conduz a animação com a interação, vendo a largura e a posição do indicador mudar de uma seção para a próxima, rastreando perfeitamente com a rolagem.
Talvez você não tenha percebido, mas tenho muito orgulho da transição de cor conforme o item de navegação destacado é selecionado.
O cinza mais claro não selecionado aparece ainda mais afastado quando o item destacado tem mais contraste. É comum fazer a transição de cores para o texto, como ao passar o cursor e quando selecionado, mas é o próximo nível de transição dessa cor na rolagem, sincronizada com o indicador de sublinhado.
Veja como 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 guias precisa dessa nova animação de cor, rastreando a mesma linha do tempo de rolagem que o indicador de sublinhado. Uso a mesma linha do tempo de antes: como a função dele é emitir uma marcação na rolagem, podemos usá-la em qualquer tipo de animação que quisermos. Como fiz antes, crio quatro frames-chave 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 frame-chave com a cor var(--text-active-color)
destaca o link.
Caso contrário, terá uma cor de texto padrão. O loop aninhado o torna relativamente
direto, já que o loop externo é cada item de navegação e o loop interno são os
frames-chave pessoais de cada item de navegação. Conferi se o elemento de loop externo é o mesmo
que o loop interno e uso para saber quando ele é selecionado.
Me diverti muito escrevendo. Demais.
Ainda mais melhorias de JavaScript
Vale lembrar que a essência do que estou mostrando aqui funciona sem JavaScript. Vamos ver como podemos aprimorá-lo quando o JS estiver disponível.
Links diretos
Links diretos são mais do que um termo para dispositivos móveis, mas acho que a intenção do link direto é
encontrada aqui com guias, nas quais é possível compartilhar um URL diretamente para o conteúdo de uma guia. O navegador navegará in-page até o ID que corresponde ao hash do URL. Descobri que
este gerenciador onload
criou o efeito em todas as plataformas.
window.onload = () => {
if (location.hash) {
tabsection.scrollLeft = document
.querySelector(location.hash)
.offsetLeft;
}
}
Rolar final sincronização
Nossos usuários nem sempre estão clicando ou usando um teclado. Às vezes, eles estão apenas rolando livremente, como deveriam. Quando o botão de rolagem da seção para de rolar a tela, o ponto em que ele chega 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 estiverem sendo roladas, limpe o tempo limite, se houver, e inicie um novo. Quando as seções pararem de rolar a tela, não apague o tempo limite e dispare 100 ms após o repouso. Quando disparar, chame uma 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 a rolagem tenha sido direcionada, a divisão da posição de rolagem atual pela largura da área de rolagem deve resultar em um número inteiro e não um decimal. Em seguida, tento pegar um navitem no cache por esse índice calculado e, se 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();
};
A configuração da guia ativa começa limpando qualquer guia ativa no momento e, em seguida, atribuindo
ao item de navegação o atributo de estado ativo. A chamada para scrollIntoView()
traz uma interação divertida com o CSS que vale a pena ressaltar.
.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 tiver tolerância a movimentos. O JavaScript pode fazer
chamadas livremente para rolar elementos até a visualização, e o CSS pode gerenciar a UX de maneira declarativa.
O jogo que eles fazem às vezes.
Conclusão
Agora que você sabe como eu fiz isso, como você iria?! Isso resulta em uma arquitetura de componentes divertida. Quem vai fazer a 1a 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 para você e vou adicionar à seção Remixes da comunidade abaixo.
Remixes da comunidade
- @devnook, @rob_dodson e @DasSurma com componentes da Web: artigo (links em inglês).
- @jhvanderschee com os botões Codepen.