Como criar um componente de guias

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

Neste post, 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.

Demo

Se preferir vídeos, confira a versão desta postagem no YouTube:

Visão geral

As guias são um componente comum dos sistemas de design, mas podem ter várias formas e formatos. 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 de física. Todos estão tentando fazer a mesma coisa: economizar espaço.

Hoje, o essencial da experiência do usuário com as 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 à grande diversidade de estilos que a Web aplicou ao conceito do componente
Uma colagem de estilos de design da Web de componentes de guias dos últimos 10 anos

Web Tactics

No geral, achei esse componente muito simples de criar, graças a alguns recursos essenciais da plataforma da Web:

  • scroll-snap-points para interações elegantes de deslize e teclado com posições de parada de rolagem adequadas
  • Links diretos usando hashes de URL para suporte ao compartilhamento e ancoragem de rolagem na página processada pelo navegador
  • Suporte a leitores de tela com marcação de elementos <a> e id="#hash"
  • prefers-reduced-motion para ativar transições de transição cruzada e rolagem instantânea na página
  • O recurso da Web @scroll-timeline em rascunho para sublinhar e mudar a cor da guia selecionada de forma dinâmica

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 conforme o navegador rola até o elemento correspondente.

Há alguns membros de conteúdo estrutural: links e :targets. Precisamos de uma lista de links, para que um <nav> é ótimo, e uma lista de elementos <article>, para que um <section> é ótimo. Cada hash de link corresponde a uma seção, permitindo que o navegador role as coisas por meio de 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 normalmente. É conteúdo complementar, conforme indicado no markup.

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, como esta:

<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 títulos de diferentes comprimentos e conjuntos de imagens. Com o conteúdo, podemos começar a trabalhar no layout.

Layouts de rolagem

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

  • A navegação (cor-de-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 delimitam 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
    Neste layout, são os contêineres de lista: links de navegação, seções de artigos e conteúdo de artigos.

Layout do <snap-tabs>

O layout de nível superior que escolhi foi o flex (Flexbox). Defino a direção como column, para que o cabeçalho e a seção sejam ordenados verticalmente. Essa é nossa primeira janela de rolagem, e ela oculta tudo com overflow hidden. O cabeçalho e a seção vão usar o overscroll em breve, como zonas individuais.

HTML
<snap-tabs>
  <header></header>
  <section></section>
</snap-tabs>
CSS
  snap-tabs {
  display: flex;
  flex-direction: column;

  /* establish primary containing box */
  overflow: hidden;
  position: relative;

  & > section {
    /* be pushy about consuming all space */
    block-size: 100%;
  }

  & > header {
    /* defend against 
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 nos ajudam a ver as janelas que os contêineres de rolagem criaram.

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

Layout de <header> das guias

O próximo layout é quase o mesmo: uso o flex para criar a 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. Não há elementos posicionados de forma absoluta aqui.

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

Em seguida, os estilos de rolagem. Podemos compartilhar os estilos de rolagem entre as duas áreas de rolagem horizontal (cabeçalho e seção). Por isso, 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 um precisa de transbordamento na direção x, contenção de rolagem para capturar o excesso de rolagem, barras de rolagem ocultas para dispositivos com tela touch e, por fim, ajuste de rolagem para travar áreas de apresentação de conteúdo. A ordem das guias do teclado é acessível, e todas as interações guiam o foco de forma natural. Os contêineres de ajuste de rolagem também recebem uma boa 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 ser encaixado no contêiner de ajuste de rolagem. Trabalho rápido para o CSS 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 estilos e tamanhos próprios. Portanto, 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 renderizará uma barra de rolagem ou não.

Os elementos de navegação têm sobreposições em rosa-choque, delineando o espaço que ocupam no componente e onde eles transbordam

Layout de <section> das guias

Essa 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% estica esse elemento para preencher o elemento pai o máximo possível. Em seguida, 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 disséssemos "expanda verticalmente o máximo possível, de forma forçada" (lembre-se do cabeçalho que definimos como flex-shrink: 0: ele é uma defesa contra esse empuxe de expansão), que define a altura da linha para um conjunto de colunas de altura total. O estilo auto-flow instrui a grade a sempre posicionar os filhos em uma linha horizontal, sem quebra de linha, exatamente o que queremos: transbordar a janela mãe.

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

Às vezes, acho difícil entender essas coisas. Esse elemento de seção se encaixa em uma caixa, mas também cria um conjunto de caixas. Espero que as imagens e explicações estejam ajudando.

Layout de <article> das guias

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

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

Eu escolhi que os artigos fossem fixados no scroller pai. Eu gosto muito de como os itens de link de navegação e os elementos do artigo se encaixam no início inline dos respectivos contêineres de rolagem. Parece e é um relacionamento harmonioso.

o elemento &quot;article&quot; e os elementos filhos dele têm sobreposições em rosa-choque, delimitando 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 de visualização que queremos fornecer a UX de rolagem. Isso significa que não preciso de nenhum estilo de altura ou largura aqui, só preciso definir como ele transborda. Defino overflow-y como "auto" e, em seguida, capturo as interações de rolagem com a propriedade overscroll-behavior.

Resumo de três áreas de rolagem

Abaixo, escolhi "sempre mostrar barras de rolagem" nas configurações do sistema. Acho que é duplamente importante que o layout funcione com essa configuração ativada, assim como é para mim analisar o layout e a orquestração de 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 mostrar a régua da barra de rolagem nesse componente ajuda a mostrar claramente onde estão as áreas de rolagem, a direção que elas suportam e como elas interagem entre si. Considere como cada um desses frames de janela de rolagem também são flexíveis ou pais de grade para um layout.

O DevTools pode ajudar a visualizar isso:

as áreas de rolagem têm sobreposições de grade e ferramentas de 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 com muitos elementos de âncora, o layout da seção de grade com muitos elementos de artigo e os elementos de artigo com muitos parágrafos e um elemento de título.

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

Destaque do recurso

Os filhos encaixados na rolagem mantêm a posição bloqueada durante o redimensionamento. Isso significa que o JavaScript não precisa mostrar nada na rotação do dispositivo ou no redimensionamento do navegador. Teste no Modo dispositivo do Chromium DevTools selecionando qualquer modo, exceto Responsivo, e redimensionando o frame do dispositivo. Observe que o elemento permanece visível e bloqueado com o conteúdo. Essa opção está disponível desde que o Chromium atualizou a implementação para corresponder à especificação. Confira uma postagem do blog sobre o assunto.

Animação

O objetivo do trabalho de animação aqui é vincular claramente as interações com o feedback da interface. Isso ajuda a orientar ou auxiliar o usuário na descoberta (espera-se 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 adoro 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 um alinhamento bonito, ele também ancora o início e o fim de uma animação. Isso mantém a <nav>, que funciona como um minimapa, conectada ao conteúdo. Vamos verificar a preferência de movimento do usuário no CSS e no JS. Há alguns lugares ótimos para isso.

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 o 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 em relação ao movimento reduzido. Dessa forma, só introduzimos o movimento de rolagem para pessoas que concordam com isso.

Indicador de guias

O objetivo dessa animação é ajudar a associar o indicador ao estado do conteúdo. Decidi colorir estilos de border-bottom de transição suave para usuários que preferem movimentos reduzidos e uma animação de transição suave de cor + rolagem vinculada para usuários que não se importam com o 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;
  }
}

Eu oculto o .snap-indicator quando o usuário prefere a redução de movimento, já que não preciso mais dele. Em seguida, substituo por estilos border-block-end e um transition. Observe também na interação com as guias que o item de navegação ativo não só tem um destaque 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 acento de luz de fundo brilhante.

Apenas algumas linhas extras de CSS vão fazer com que alguém se sinta notado, no sentido de que estamos respeitando as preferências de movimento. Adoro isso.

@scroll-timeline

Na seção acima, mostrei como processar os estilos de transição suave de movimento reduzido. Nesta seção, vou mostrar como conectei o indicador e uma área de rolagem. Agora vamos falar sobre 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 um movimento reduzido, nenhum dos efeitos de movimento de vinculação de rolagem será executado.

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

No momento da redação deste artigo, o suporte do navegador para @scroll-timeline é inexistente. É uma especificação de rascunho com apenas implementações experimentais. No entanto, ele tem um polyfill, que uso nesta demonstração.

ScrollTimeline

Embora o CSS e o JavaScript possam criar linhas do tempo de rolagem, optei pelo JavaScript para usar as 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 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 um período 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 entrar nos 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, pontos de estado para animar, também conhecidos como keyframes.

Frames-chave dinâmicos

Há uma maneira realmente poderosa de usar CSS declarativo puro para 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 criar dinamicamente um número de frames-chave com base no comprimento das crianças.

No entanto, o JavaScript sabe como conseguir essas informações. Por isso, vamos iterar sobre os elementos filhos e extrair os valores calculados 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, desfaça a estrutura da 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 qual é a largura dinâmica e, em seguida, é usado como um valor de keyframe.

Confira um exemplo de saída com base nas minhas fontes e preferências de 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 keyframes, dependendo da posição de ajuste de rolagem do seletor de seções. Os pontos de ajuste criam uma delimitação clara entre nossos frames-chave e realmente aumentam a sensação de sincronização da animação.

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

O usuário controla 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 notado, mas estou muito orgulhoso da transição de cores quando o item de navegação destacado é selecionado.

O cinza mais claro não selecionado aparece ainda mais 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 mais avançado fazer a transição dessa cor ao rolar, 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 guia precisa dessa nova animação de cor, rastreando a mesma linha de tempo de rolagem do indicador sublinhado. Usei a mesma linha do tempo de antes: como a função dela é emitir uma marcação no rolagem, podemos usar essa marcação em qualquer tipo de animação. Como fiz antes, criei quatro keyframes no loop e retornei cores.

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

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

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

Foi muito divertido escrever isso. Demais.

Mais melhorias em 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 estiver disponível.

Os links diretos são mais um termo para dispositivos móveis, mas acho que a intenção deles é seguida aqui com as guias, em que você pode 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 aplicou o efeito em várias 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 página, como deveriam poder fazer. Quando o controle deslizante da seção parar de rolar, o local em que ele parar precisa ser correspondente à barra de navegação na parte de cima.

Veja como eu espero pelo fim do 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 da seção, se houver, e inicie uma nova. Quando as seções pararem de rolar, não limpe o tempo limite e dispare 100 ms após o descanso. Quando ele é acionado, chame a função que procura 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 esteja fixada, dividir a posição de rolagem atual pela largura da área de rolagem deve resultar em um número inteiro, não decimal. Em seguida, tento extrair um navitem do nosso cache usando esse índice calculado. Se ele encontrar algo, envio a correspondência para ser ativada.

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

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

A definição da guia ativa começa limpando qualquer guia atualmente ativa e, em seguida, atribuindo 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, aninhado, criamos uma consulta de mídia que aplica a rolagem smooth se o usuário for tolerante a movimentos. O JavaScript pode fazer chamadas livremente para rolar elementos na visualização, e o CSS pode gerenciar a UX de forma declarativa. Às vezes, eles fazem uma combinação maravilhosa.

Conclusão

Agora que você sabe como eu fiz, como você faria? Isso cria uma arquitetura de componentes divertida. Quem vai criar a primeira versão com slots na framework favorita? 🙂

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 adicionar à seção Remixes da comunidade abaixo.

Remixes da comunidade