Como criar um componente de guias

Uma visão geral fundamental de como criar um componente de guias semelhante aos encontrados em apps iOS e Android.

Nesta postagem, quero compartilhar ideias sobre como criar um componente de guias para a Web que seja responsivo, ofereça suporte a várias entradas de dispositivos e funcione em todos os navegadores. Teste a demonstração.

Demonstração

Se preferir vídeo, confira uma versão desta postagem no YouTube:

Visão geral

As guias são um componente comum dos sistemas de design, mas podem ter muitas formas e formatos. Primeiro, havia guias de computador criadas no elemento <frame>, e agora temos componentes móveis suaves que animam o conteúdo com base em propriedades físicas. Todos eles tentam fazer a mesma coisa: economizar espaço.

Hoje, o essencial de uma experiência do usuário com guias é uma área de navegação por botões que alterna a visibilidade do conteúdo em um frame de exibição. Muitas áreas de conteúdo diferentes compartilham o mesmo espaço, mas são apresentadas condicionalmente com base no botão selecionado na navegação.

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

Táticas da Web

No geral, achei esse componente bem simples de criar, graças a alguns recursos críticos da plataforma da Web:

  • scroll-snap-points para interações elegantes de deslizar e teclado com posições de parada de rolagem adequadas
  • Links diretos via hashes de URL para ancoragem de rolagem na página processada pelo navegador e suporte ao compartilhamento
  • Suporte a leitores de tela com marcação de elementos <a> 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 em rascunho para sublinhar dinamicamente e mudar a cor da guia selecionada

O HTML

Basicamente, a UX aqui é: clique em um link, faça com que o URL represente o estado da página aninhada e veja a área de conteúdo ser atualizada à medida que o navegador rola até o elemento correspondente.

Há alguns membros de conteúdo estrutural: links e :targets. Precisamos de uma lista de links, que um <nav> é ótimo para isso, e uma lista de elementos <article>, que um <section> é ótimo para isso. Cada hash de link corresponde a uma seção, permitindo que o navegador role as coisas por ancoragem.

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 necessidade de JS. O usuário pode rolar o conteúdo do artigo com o dispositivo de entrada como sempre. É um conteúdo complementar, conforme indicado na marcação.

Usei a seguinte marcação para organizar as guias:

<snap-tabs>
  <header>
    <nav>
      <a></a>
      <a></a>
      <a></a>
      <a></a>
    </nav>
  </header>
  <section>
    <article></article>
    <article></article>
    <article></article>
    <article></article>
  </section>
</snap-tabs>

Posso estabelecer conexões entre os elementos <a> e <article> com as propriedades href e id desta forma:

<snap-tabs>
  <header>
    <nav>
      <a href="#responsive"></a>
      <a href="#accessible"></a>
      <a href="#overscroll"></a>
      <a href="#more"></a>
    </nav>
  </header>
  <section>
    <article id="responsive"></article>
    <article id="accessible"></article>
    <article id="overscroll"></article>
    <article id="more"></article>
  </section>
</snap-tabs>

Em seguida, preenchi os artigos com quantidades variadas de lorem e os links com um conjunto de títulos de comprimento e imagens variados. Com o conteúdo para trabalhar, podemos começar o layout.

Layouts roláveis

Há três tipos diferentes de áreas de rolagem neste componente:

  • A navegação (rosa) pode ser rolada horizontalmente
  • A área de conteúdo (azul) pode ser rolada horizontalmente
  • Cada item do artigo (verde) pode ser rolado verticalmente.
Três caixas coloridas com setas direcionais correspondentes que descrevem as áreas de rolagem e mostram a direção em que elas vão rolar.

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 grande
    Nesse layout, são os contêineres de lista: links de navegação, artigos de seção e conteúdo de artigos.

Layout do <snap-tabs>

O layout de nível superior escolhido foi flex (Flexbox). Defini a direção como column, para que o cabeçalho e a seção sejam ordenados verticalmente. Esta é nossa primeira janela de rolagem, e ela oculta tudo com o estouro oculto. Em breve, o cabeçalho e a seção vão usar o overscroll como zonas individuais.

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

  • <header> está preparado para ser o contêiner de rolagem (rosa).
  • <section> está preparado para ser o contêiner de rolagem (azul).

Os frames que destaquei abaixo com o VisBug ajudam a ver as janelas que os contêineres de rolagem criaram.

os elementos de cabeçalho e seção têm sobreposições rosa-choque, delineando o espaço que ocupam no componente

Layout de guias <header>

O próximo layout é quase o mesmo: uso o flex para criar uma 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 se mover horizontalmente com o grupo de links, e esse layout de cabeçalho ajuda a definir esse estágio. Nenhum elemento posicionado de forma absoluta aqui!

Os elementos nav e span.indicator têm sobreposições rosa-choque, delineando o espaço que ocupam no componente.

Em seguida, os estilos de rolagem. Descobri que podemos compartilhar os estilos de rolagem entre as duas áreas de rolagem horizontal (cabeçalho e seção). Por isso, criei uma classe de utilidade, .scroll-snap-x.

.scroll-snap-x {
  /* browser decide if x is ok to scroll and show bars on, y hidden */
  overflow: auto hidden;
  /* prevent scroll chaining on x scroll */
  overscroll-behavior-x: contain;
  /* scrolling should snap children on x */
  scroll-snap-type: x mandatory;

  @media (hover: none) {
    scrollbar-width: none;

    &::-webkit-scrollbar {
      width: 0;
      height: 0;
    }
  }
}

Cada um precisa de estouro no eixo x, contenção de rolagem para evitar o excesso de rolagem, barras de rolagem ocultas para dispositivos touch e, por fim, ajuste de rolagem para bloquear áreas de apresentação de conteúdo. A ordem de tabulação do teclado é acessível, e todas as interações guiam o foco naturalmente. Os contêineres de ajuste de rolagem também recebem uma interação de estilo de carrossel agradável do teclado.

Layout do cabeçalho <nav> das guias

Os links de navegação precisam ser dispostos em uma linha, sem quebras de linha, centralizados verticalmente, e cada item de link precisa se ajustar ao contêiner de ajuste de rolagem. Trabalho rápido para CSS de 2021!

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 tem um estilo e um tamanho próprios. Portanto, o layout de navegação só precisa especificar direção e fluxo. Larguras exclusivas em itens de navegação tornam a transição entre guias divertida, já que o indicador ajusta a largura para o novo destino. Dependendo de quantos elementos estão aqui, o navegador vai renderizar ou não uma barra de rolagem.

Os elementos &quot;a&quot; da navegação têm sobreposições rosa-choque, delineando o espaço que ocupam no componente e onde transbordam.

Layout de guias <section>

Essa seção é um item flexível e precisa ser o consumidor dominante de espaço. Também é necessário criar colunas para os artigos. Mais uma vez, um trabalho rápido para o CSS 2021! O block-size: 100% estica esse elemento para preencher o elemento pai o máximo possível. Depois, para o próprio layout, ele cria uma série de colunas que são 100% a largura do elemento pai. As porcentagens funcionam muito bem aqui porque escrevemos restrições fortes no elemento pai.

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 "expanda verticalmente o máximo possível, de maneira insistente" (lembre-se do cabeçalho que definimos como flex-shrink: 0: é uma defesa contra essa expansão insistente), o que define a altura da linha para um conjunto de colunas de altura total. O estilo auto-flow diz à grade para sempre dispor os filhos em uma linha horizontal, sem quebra de linha, exatamente o que queremos: transbordar a janela principal.

os elementos do artigo têm sobreposições rosa-choque, descrevendo o espaço que ocupam no componente e onde transbordam

Às vezes, acho difícil entender isso! Esse elemento de seção está se encaixando em uma caixa, mas também criou um conjunto de caixas. Espero que os recursos visuais e as explicações estejam ajudando.

Layout de guias <article>

O usuário precisa conseguir rolar o conteúdo do artigo, e as barras de rolagem só devem aparecer se houver um estouro. Esses elementos do artigo estão em uma posição organizada. Eles são simultaneamente um elemento pai e um elemento filho de rolagem. O navegador está processando algumas interações complicadas de toque, mouse e teclado para nós aqui.

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

Escolhi fazer com que os artigos se encaixassem no scroller principal. Eu gosto muito de como os itens de link de navegação e os elementos do artigo se ajustam ao início da linha dos respectivos contêineres de rolagem. Parece e soa como um relacionamento harmonioso.

o elemento &quot;article&quot; e os elementos filhos têm sobreposições rosa-choque, descrevendo o espaço que ocupam no componente e a direção em que transbordam

O artigo é um filho da grade, e o tamanho dele é predeterminado para ser a área da janela de visualização em que queremos fornecer a experiência de rolagem. Isso significa que não preciso de estilos de altura ou largura aqui, apenas definir como ele transborda. Defini overflow-y como "auto" e também capturei as interações de rolagem com a propriedade útil overscroll-behavior.

Resumo das três áreas de rolagem

Abaixo, escolhi nas configurações do sistema "sempre mostrar barras de rolagem". Acho que é duplamente importante que o layout funcione com essa configuração ativada, assim como é importante para mim analisar o layout e a organização da rolagem.

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

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

O DevTools pode ajudar a visualizar isso:

as áreas de rolagem têm sobreposições de ferramentas de grade e flexbox, descrevendo o espaço que ocupam no componente e a direção em que transbordam
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 de artigo e os elementos de artigo cheios de parágrafos e um elemento de cabeçalho.

Os layouts de rolagem estão completos: ajuste, link direto e acessibilidade do teclado. Base sólida para melhorias de UX, estilo e satisfação.

Destaque de recurso

Os filhos ajustados por rolagem mantêm a posição bloqueada durante o redimensionamento. Isso significa que o JavaScript não precisará mostrar nada quando o dispositivo for girado ou o navegador for redimensionado. Teste no Modo dispositivo do Chromium DevTools. Para isso, selecione qualquer modo que não seja Responsivo e redimensione o frame do dispositivo. Observe que o elemento permanece visível e bloqueado com o conteúdo. Isso está disponível desde que o Chromium atualizou a implementação para corresponder à especificação. Confira uma postagem no blog sobre o assunto.

Animação

O objetivo do trabalho de animação aqui é vincular claramente as interações ao feedback da interface. Isso ajuda a orientar ou auxiliar o usuário na descoberta (esperamos que) perfeita de todo o conteúdo. Vou adicionar movimento com propósito e de forma condicional. Agora os usuários podem especificar as preferências de movimento no sistema operacional, e eu gosto muito de responder a essas preferências nas minhas interfaces.

Vou vincular um sublinhado de guia à posição de rolagem do artigo. O ajuste não é apenas um alinhamento bonito, mas também a ancoragem do início e do fim de uma animação. Isso mantém o <nav>, que funciona como um minimapa, conectado ao conteúdo. Vamos verificar a preferência de movimento do usuário em CSS e JS. Há alguns lugares ótimos para ser atencioso!

Comportamento de rolagem

Há uma oportunidade de melhorar o comportamento de movimento de :target e element.scrollIntoView(). Por padrão, é instantâneo. O navegador apenas define a posição de rolagem. E se quisermos fazer a transição para essa posição de rolagem em vez de piscar?

@media (prefers-reduced-motion: no-preference) {
  .scroll-snap-x {
    scroll-behavior: smooth;
  }
}

Como estamos introduzindo movimento aqui, e um movimento que o usuário não controla (como rolagem), só aplicamos esse estilo se o usuário não tiver preferência no sistema operacional em relação à redução de movimento. Assim, só apresentamos o movimento de rolagem para quem não se importa com isso.

Indicador de guias

O objetivo dessa animação é ajudar a associar o indicador ao estado do conteúdo. Decidi usar estilos de transição gradual de cores border-bottom para usuários que preferem movimento reduzido e uma animação de deslizamento vinculada à rolagem + transição gradual de cores para usuários que não se importam com movimento.

No Chromium Devtools, posso alternar a preferência e demonstrar os dois estilos de transição diferentes. Me diverti muito criando isso.

@media (prefers-reduced-motion: reduce) {
  snap-tabs > header a {
    border-block-end: var(--indicator-size) solid hsl(var(--accent) / 0%);
    transition: color .7s ease, border-color .5s ease;

    &:is(:target,:active,[active]) {
      color: var(--text-active-color);
      border-block-end-color: hsl(var(--accent));
    }
  }

  snap-tabs .snap-indicator {
    visibility: hidden;
  }
}

Eu oculto o .snap-indicator quando o usuário prefere movimento reduzido, já que não preciso mais dele. Depois, substituo por estilos border-block-end e um transition. Observe também na interação das guias que o item de navegação ativo não tem apenas um destaque de sublinhado da marca, mas também uma cor de texto mais escura. O elemento ativo tem um contraste de cor de texto maior e um destaque de luz brilhante.

Apenas algumas linhas extras de CSS farão com que alguém se sinta visto (no sentido de que estamos respeitando cuidadosamente as preferências de movimento). Adoro isso.

@scroll-timeline

Na seção acima, mostrei como processar os estilos de transição reduzida, e nesta seção vou mostrar como vinculei o indicador e uma área de rolagem. Em seguida, vamos mostrar algumas coisas experimentais e divertidas. Espero que você esteja tão animado quanto eu.

const { matches:motionOK } = window.matchMedia(
  '(prefers-reduced-motion: no-preference)'
);

Primeiro, verifico a preferência de movimento do usuário em JavaScript. Se o resultado for false, ou seja, o usuário prefere movimento reduzido, não vamos executar nenhum dos efeitos de movimento de vinculação de rolagem.

if (motionOK) {
  // motion based animation code
}

No momento da redação deste artigo, não há suporte para @scroll-timeline em navegadores. É uma especificação de rascunho com apenas implementações experimentais. No entanto, ele tem um polyfill, que eu uso nesta demonstração.

ScrollTimeline

Embora CSS e JavaScript possam criar linhas do tempo de rolagem, optei pelo JavaScript para usar medições de elementos ativos na animação.

const sectionScrollTimeline = new ScrollTimeline({
  scrollSource: tabsection,  // snap-tabs > section
  orientation: 'inline',     // scroll in the direction letters flow
  fill: 'both',              // bi-directional linking
});

Quero que uma coisa siga a posição de rolagem de outra. Ao criar um ScrollTimeline, defino o driver do link de rolagem, o scrollSource. Normalmente, uma animação na Web é executada em relação a uma marca de tempo global, mas com um sectionScrollTimeline personalizado na memória, posso mudar tudo isso.

tabindicator.animate({
    transform: ...,
    width: ...,
  }, {
    duration: 1000,
    fill: 'both',
    timeline: sectionScrollTimeline,
  }
);

Antes de falar sobre os frames-chave da animação, acho importante destacar que o seguidor da rolagem, tabindicator, será animado com base em uma linha do tempo personalizada, a rolagem da nossa seção. Isso conclui a vinculação, mas falta o ingrediente final, que são os pontos com estado para animar, também conhecidos como quadros-chave.

Frames-chave dinâmicos

Há uma maneira muito eficiente de animar com CSS declarativo puro usando @scroll-timeline, mas a animação que escolhi fazer era muito dinâmica. Não há como fazer a transição entre a largura auto nem criar dinamicamente um número de frames-chave com base no comprimento dos elementos filhos.

No entanto, o JavaScript sabe como extrair essas informações. Por isso, vamos iterar sobre os filhos e extrair os valores calculados no tempo de execução:

tabindicator.animate({
    transform: [...tabnavitems].map(({offsetLeft}) =>
      `translateX(${offsetLeft}px)`),
    width: [...tabnavitems].map(({offsetWidth}) =>
      `${offsetWidth}px`)
  }, {
    duration: 1000,
    fill: 'both',
    timeline: sectionScrollTimeline,
  }
);

Para cada tabnavitem, desestruture a posição offsetLeft e retorne uma string que a use como um valor translateX. Isso cria quatro frames-chave de transformação para a animação. O mesmo é feito para a largura. Cada elemento pergunta qual é a largura dinâmica e, em seguida, ela é usada como um valor de keyframe.

Confira um exemplo de saída com base nas minhas fontes e preferências do navegador:

Frames-chave TranslateX:

[...tabnavitems].map(({offsetLeft}) =>
  `translateX(${offsetLeft}px)`)

// results in 4 array items, which represent 4 keyframe states
// ["translateX(0px)", "translateX(121px)", "translateX(238px)", "translateX(464px)"]

Frames-chave de largura:

[...tabnavitems].map(({offsetWidth}) =>
  `${offsetWidth}px`)

// results in 4 array items, which represent 4 keyframe states
// ["121px", "117px", "226px", "67px"]

Para resumir a estratégia, o indicador de guia agora vai animar em quatro keyframes dependendo da posição de ajuste de rolagem do scroller da seção. Os pontos de ajuste criam uma delimitação clara entre os frames-chave e contribuem para a sensação sincronizada da animação.

A guia ativa e a guia inativa são mostradas com sobreposições do VisBug, que exibem pontuações de contraste aprovadas para ambas.

O usuário controla a animação com a interação, vendo a largura e a posição do indicador mudarem de uma seção para a próxima, acompanhando perfeitamente a rolagem.

Talvez você não tenha percebido, mas tenho muito orgulho da transição de cor quando o item de navegação destacado é selecionado.

O cinza claro não selecionado parece ainda mais recuado quando o item destacado tem mais contraste. É comum fazer a transição da cor do texto, como ao passar o cursor e quando selecionado, mas é um nível superior fazer a transição dessa cor ao rolar, sincronizada com o indicador de sublinhado.

Foi assim que eu fiz isso:

tabnavitems.forEach(navitem => {
  navitem.animate({
      color: [...tabnavitems].map(item =>
        item === navitem
          ? `var(--text-active-color)`
          : `var(--text-color)`)
    }, {
      duration: 1000,
      fill: 'both',
      timeline: sectionScrollTimeline,
    }
  );
});

Cada link de navegação por guias precisa dessa nova animação de cor, acompanhando a mesma linha do tempo de rolagem que o indicador de sublinhado. Uso a mesma linha do tempo de antes: como a função dela é emitir um tique ao rolar, podemos usar esse tique em qualquer tipo de animação que quisermos. Como fiz antes, crio quatro keyframes no loop e retorno cores.

[...tabnavitems].map(item =>
  item === navitem
    ? `var(--text-active-color)`
    : `var(--text-color)`)

// results in 4 array items, which represent 4 keyframe states
// [
  "var(--text-active-color)",
  "var(--text-color)",
  "var(--text-color)",
  "var(--text-color)",
]

O keyframe com a cor var(--text-active-color) destaca o link, e ele é uma cor de texto padrão. O loop aninhado torna isso relativamente simples, já que o loop externo é cada item de navegação, e o loop interno é cada keyframe pessoal do item de navegação. Verifico se o elemento do loop externo é o mesmo do loop interno e uso isso para saber quando ele está selecionado.

Me diverti muito escrevendo isso. Demais.

Ainda mais melhorias no JavaScript

Vale lembrar que o núcleo do que estou mostrando aqui funciona sem JavaScript. Dito isso, vamos ver como podemos melhorar quando o JS está disponível.

Os links diretos são mais um termo para dispositivos móveis, mas acho que a intenção deles é atendida aqui com as guias, já que você pode compartilhar um URL diretamente com o conteúdo de uma guia. O navegador vai navegar na página até o ID correspondente no hash do URL. Encontrei este manipulador onload que fez o efeito em todas as plataformas.

window.onload = () => {
  if (location.hash) {
    tabsection.scrollLeft = document
      .querySelector(location.hash)
      .offsetLeft;
  }
}

Sincronização do fim da rolagem

Nossos usuários nem sempre clicam ou usam um teclado. Às vezes, eles apenas rolam a tela livremente, como deveriam poder fazer. Quando o scroller de seção para de rolar, o local em que ele para precisa ser correspondido na barra de navegação superior.

Veja como aguardo o fim da rolagem: js tabsection.addEventListener('scroll', () => { clearTimeout(tabsection.scrollEndTimer); tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100); });

Sempre que as seções forem roladas, limpe o tempo limite da seção, se houver, e inicie um novo. Quando as seções pararem de ser roladas, não limpe o tempo limite e acione 100 ms após o descanso. Quando ele for disparado, chame a função que tenta descobrir onde o usuário parou.

const determineActiveTabSection = () => {
  const i = tabsection.scrollLeft / tabsection.clientWidth;
  const matchingNavItem = tabnavitems[i];

  matchingNavItem && setActiveTab(matchingNavItem);
};

Supondo que a rolagem tenha sido ajustada, dividir a posição atual da rolagem pela largura da área de rolagem deve resultar em um número inteiro, não um decimal. Em seguida, tento extrair um navitem do nosso cache usando esse índice calculado e, se ele encontrar algo, envio a correspondência para ser definida como ativa.

const setActiveTab = tabbtn => {
  tabnav
    .querySelector(':scope a[active]')
    .removeAttribute('active');

  tabbtn.setAttribute('active', '');
  tabbtn.scrollIntoView();
};

Para definir a guia ativa, primeiro limpe qualquer guia ativa no momento e atribua o estado ativo ao item de navegação recebido. A chamada para scrollIntoView() tem uma interação divertida com CSS que vale a pena notar.

.scroll-snap-x {
  overflow: auto hidden;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;

  @media (prefers-reduced-motion: no-preference) {
    scroll-behavior: smooth;
  }
}

No CSS do utilitário de ajuste de rolagem horizontal, aninhamos uma consulta de mídia que aplica a rolagem smooth se o usuário não tiver restrições de movimento. O JavaScript pode fazer chamadas livremente para rolar elementos até a visualização, e o CSS pode gerenciar a UX de forma declarativa. Às vezes, eles formam um casal encantador.

Conclusão

Agora que você sabe como eu fiz isso, como você faria? Isso resulta em uma arquitetura de componentes divertida! Quem vai criar a primeira versão com slots no framework favorito? 🙂

Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web. Crie um Glitch, me envie um tweet com sua versão, e eu vou adicioná-la à seção Remixes da comunidade abaixo.

Remixes da comunidade