Como criar um componente de guias

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.

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.

a colagem é bastante caótica devido à enorme diversidade de estilos que a web aplicou ao conceito do componente.
Uma colagem de estilos de web design de componentes de guias dos últimos 10 anos

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> e id="#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 :targets. 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.

Um botão de link é clicado, deslizando o conteúdo em foco

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.
Três caixas coloridas com setas direcionais correspondentes a cada cor que contornam as áreas de rolagem e mostram a direção de rolagem.

Há dois tipos diferentes de elementos envolvidos na rolagem:

  1. Uma janela
    Uma caixa com dimensões definidas que tem o estilo de propriedade overflow.
  2. 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.

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 
needing 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.

os elementos de cabeçalho e seção têm sobreposições de hotpink, destacando o espaço que ocupam no componente

Layout das guias <header>

O próximo layout é quase o mesmo: uso flexível para criar ordenação vertical.

HTML
<snap-tabs>
  <header>
    <nav></nav>
    <span class="snap-indicator"></span>
  </header>
  <section></section>
</snap-tabs>
CSS
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.

os elementos nav e span.indicator têm sobreposições de hotpink, destacando o espaço que ocupam no componente.

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!

HTML
<nav>
  <a></a>
  <a></a>
  <a></a>
  <a></a>
</nav>
CSS
  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.

os elementos a da navegação têm sobreposições de hotpink, destacando o espaço que ocupam no componente e também onde eles estão sobrecarregando

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.

HTML
<section>
  <article></article>
  <article></article>
  <article></article>
  <article></article>
</section>
CSS
  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.

os elementos do artigo têm sobreposições de hotpink, destacando o espaço que ocupam no componente e onde eles ultrapassam

À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.

HTML
<article>
  <h2></h2>
  <p></p>
  <p></p>
  <h2></h2>
  <p></p>
  <p></p>
  ...
</article>
CSS
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 elemento do artigo e seus elementos secundários têm sobreposições de hotpink, destacando o espaço que ocupam no componente e a direção em que estouram

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.

As três barras de rolagem estão definidas para serem exibidas, consumindo espaço de layout, e nosso componente ainda está ótimo

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:

as áreas de rolagem têm sobreposições de ferramentas Grid e flexbox, destacando o espaço que ocupam no componente e a direção em que transitam.
Chromium Devtools, mostrando o layout do elemento de navegação flexbox cheio de elementos de âncora, o layout da seção de grade cheio de elementos do artigo e os elementos do artigo cheios de parágrafos e um elemento de cabeçalho.

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.

as guias ativa e inativa são mostradas com sobreposições de VisBug que mostram pontuações de contraste passando para ambos

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 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