Análise detalhada de CSS: matrix3d() para ter uma barra de rolagem personalizada com perfeição

As barras de rolagem personalizadas são extremamente raras, principalmente porque elas são um dos poucos elementos da Web que não podem ser estilados (estou falando de você, seletor de datas). Você pode usar o JavaScript para criar o seu, mas isso é caro, de baixa fidelidade e pode parecer lento. Neste artigo, vamos aproveitar algumas matrizes CSS não convencionais para criar um scroller personalizado que não requer JavaScript durante a rolagem, apenas um código de configuração.

Texto longo, leia o resumo

Você não se importa com as pequenas coisas? Você só quer conferir a demonstração do Nyan Cat e usar a biblioteca? Veja o código da demonstração no nosso repositório do GitHub (link em inglês).

LAM;WRA (longa e matemática; será lida de qualquer maneira)

Há algum tempo, criamos um botão de rolagem de paralaxe. Você leu este artigo? É muito bom, vale a pena. Ao empurrar os elementos de volta usando transformações CSS 3D, os elementos foram movidos mais lentamente do que a velocidade real de rolagem.

Recapitulação

Vamos começar recapitulando como o botão de rolagem de paralaxe funcionava.

Como mostrado na animação, conseguimos o efeito de paralaxe empurrando elementos "para trás" no espaço 3D, ao longo do eixo Z. Rolar um documento é uma translação ao longo do eixo Y. Portanto, se rolarmos para baixo, digamos, 100 px, cada elemento será movido para cima em 100 px. Isso se aplica a todos os elementos, mesmo aqueles que estão "mais atrás". Mas como eles estão mais distantes da câmera, o movimento observado na tela será menor que 100 px, gerando o efeito de paralaxe desejado.

É claro que mover um elemento para trás no espaço também vai fazer com que ele pareça menor, o que podemos corrigir redimensionando o elemento. Descobrimos a matemática exata quando criamos o controle de paralaxe, então não vou repetir todos os detalhes.

Etapa 0: o que queremos fazer?

Barras de rolagem. É isso que vamos criar. Mas você já pensou sobre o que eles fazem? Eu com certeza não. As barras de rolagem são um indicador de quanto do conteúdo disponível está visível no momento e quanto progresso você fez como leitor. Se você rolar para baixo, a barra de rolagem vai rolar para indicar que você está progredindo em direção ao final. Se todo o conteúdo couber na viewport, a barra de rolagem geralmente será oculta. Se o conteúdo tiver o dobro da altura da janela de visualização, a barra de rolagem vai preencher um terço da altura dela. Conteúdo com o triplo da altura da janela de visualização dimensiona a barra de rolagem para ⅓ da janela de visualização etc. Você entendeu o padrão. Em vez de rolar, você também pode clicar e arrastar a barra de rolagem para navegar pelo site mais rapidamente. Essa é uma quantidade surpreendente de comportamento para um elemento discreto como esse. Vamos lutar uma batalha de cada vez.

Etapa 1: reverter a direção

É possível deixar os elementos mais lentos do que a velocidade de rolagem com as transformações CSS 3D, conforme descrito no artigo sobre rolagem paralaxe. Também é possível reverter a direção? Acontece que podemos, e essa é a maneira de criar uma barra de rolagem personalizada com o tamanho exato do frame. Para entender como isso funciona, primeiro precisamos abordar alguns conceitos básicos do CSS 3D.

Para conseguir qualquer tipo de projeção em perspectiva no sentido matemático, é mais provável que você use coordenadas homogêneas. Não vou entrar em detalhes sobre o que elas são e por que funcionam, mas você pode pensar nelas como coordenadas 3D com uma quarta coordenada adicional chamada w. Essa coordenada precisa ser 1, a menos que você queira ter distorção de perspectiva. Não precisamos nos preocupar com os detalhes de w, porque não vamos usar nenhum outro valor além de 1. Portanto, todos os pontos são vetores de 4 dimensões [x, y, z, w=1] e, consequentemente, as matrizes também precisam ser 4x4.

Uma ocasião em que você pode notar que o CSS usa coordenadas homogêneas é quando você define suas próprias matrizes 4x4 em uma propriedade de transformação usando a função matrix3d(). matrix3d usa 16 argumentos (porque a matriz é 4x4), especificando uma coluna após a outra. Podemos usar essa função para especificar manualmente rotações, translações etc., mas ela também permite mexer com a coordenada w.

Antes de usar matrix3d(), precisamos de um contexto 3D, porque sem um contexto 3D não há distorção de perspectiva e não é necessário usar coordenadas homogêneas. Para criar um contexto 3D, precisamos de um contêiner com uma perspective e alguns elementos dentro que podemos transformar no espaço 3D recém-criado. Por exemplo:

Uma parte de código CSS que distorce um div usando o atributo de perspectiva do CSS.

Os elementos dentro de um contêiner de perspectiva são processados pelo mecanismo de CSS da seguinte maneira:

  • Transforme cada canto (vértic) de um elemento em coordenadas [x,y,z,w] homogêneas em relação ao contêiner de perspectiva.
  • Aplique todas as transformações do elemento como matrizes da direita para a esquerda.
  • Se o elemento de perspectiva for rolável, aplique uma matriz de rolagem.
  • Aplique a matriz de perspectiva.

A matriz de rolagem é uma tradução ao longo do eixo Y. Se rolarmos para baixo 400 px, todos os elementos precisam ser movidos para cima 400 px. A matriz de perspectiva é uma matriz que “puxa” os pontos para mais perto do ponto de fuga à medida que eles se afastam no espaço 3D. Isso faz com que as coisas pareçam menores quando estão mais distantes e também as faz "se moverem mais devagar" quando são traduzidas. Portanto, se um elemento for empurrado para trás, uma translação de 400 px fará com que o elemento se mova apenas 300 px na tela.

Se você quiser saber todos os detalhes, leia a especificação no modelo de renderização de transformação do CSS. No entanto, para este artigo, simplifiquei o algoritmo acima.

Nossa caixa está dentro de um contêiner de perspectiva com o valor p para o atributo perspective. Vamos supor que o contêiner pode ser rolado e que ele é rolado para baixo em n pixels.

A matriz de perspectiva vezes a matriz de rolagem vezes a matriz de transformação de elementos
  é igual a uma matriz de identidade de quatro por quatro com menos um sobre p na quarta linha
  coluna terceira vezes uma matriz de identidade de quatro por quatro com menos n na segunda
  linha quarta coluna vezes a matriz de transformação de elementos.

A primeira matriz é a de perspectiva, e a segunda é a de rolagem. Para recapitular: o trabalho da matriz de rolagem é fazer com que um elemento se mova para cima quando estamos rolando para baixo, daí o sinal negativo.

No entanto, para a barra de rolagem, queremos o contrário: queremos que o elemento se mova para baixo quando rolarmos para baixo. Aqui é onde podemos usar um truque: invertendo a coordenada w dos cantos da caixa. Se a coordenada w for -1, todas as traduções vão entrar em vigor na direção oposta. Como fazer isso? O mecanismo do CSS converte os cantos da caixa em coordenadas homogêneas e define w como 1. É hora de matrix3d() brilhar!

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    );
}

Essa matriz não vai fazer nada além de negar w. Portanto, quando o mecanismo do CSS transforma cada canto em um vetor do formulário [x,y,z,1], a matriz o converte em [x,y,z,-1].

Matriz identidade de quatro por quatro com menos um sobre p na quarta linha
  coluna terceira vezes a matriz identidade de quatro por quatro com menos n na segunda
  linha coluna quarta vezes a matriz identidade de quatro por quatro com menos um na
  quarta linha coluna quarta vezes o vetor de quatro dimensões x, y, z, 1 é igual a quatro
  matriz identidade de quatro por quatro com menos um sobre p na quarta linha terceira coluna,
  menos n na segunda linha coluna quarta e menos um na quarta linha
  coluna quarta é igual ao vetor de quatro dimensões x, y, z, menos z sobre
  p menos 1.

Listei uma etapa intermediária para mostrar o efeito da matriz de transformação de elementos. Se você não se sente confortável com a matemática de matriz, tudo bem. O momento Eureka é quando, na última linha, adicionamos o deslocamento de rolagem n à nossa coordenada y em vez de subtrair. O elemento será movido para baixo se rolarmos para baixo.

No entanto, se colocarmos essa matriz no exemplo, o elemento não será mostrado. Isso ocorre porque a especificação do CSS exige que qualquer vértice com w < 0 bloqueie a renderização do elemento. Como nossa coordenada z é atualmente 0 e p é 1, w será -1.

Felizmente, podemos escolher o valor de z. Para garantir que terminemos com w=1, precisamos definir z = -2.

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    )
    translateZ(-2px);
}

Nossa, nossa caixa está de volta.

Etapa 2: fazer com que ele se mova

Agora nossa caixa está lá e tem a mesma aparência que teria sem transformações. No momento, o contêiner de perspectiva não pode ser rolado, então não podemos vê-lo, mas sabemos que nosso elemento vai rolar na outra direção quando rolarmos. Vamos fazer o contêiner rolar, certo? Podemos adicionar um elemento de espaço que ocupa espaço:

<div class="container">
    <div class="box"></div>
    <span class="spacer"></span>
</div>

<style>
/* … all the styles from the previous example … */
.container {
    overflow: scroll;
}
.spacer {
    display: block;
    height: 500px;
}
</style>

Agora role a caixa. A caixa vermelha se move para baixo.

Etapa 3: defina o tamanho

Temos um elemento que se move para baixo quando a página rola para baixo. Agora que a parte difícil acabou. Agora precisamos estilizar para que pareça uma barra de rolagem e tornar um pouco mais interativa.

Uma barra de rolagem geralmente consiste em um "círculo" e uma "faixa", e a faixa nem sempre está visível. A altura do polegar é diretamente proporcional à quantidade de conteúdo visível.

<script>
    const scroller = document.querySelector('.container');
    const thumb = document.querySelector('.box');
    const scrollerHeight = scroller.getBoundingClientRect().height;
    thumb.style.height = /* ??? */;
</script>

scrollerHeight é a altura do elemento rolável, enquanto scroller.scrollHeight é a altura total do conteúdo rolável. scrollerHeight/scroller.scrollHeight é a fração do conteúdo que está visível. A proporção do espaço vertical que as capas de círculo precisa ser igual à proporção de conteúdo visível:

O tamanho do ponto do estilo do ponto do polegar sobre a altura do scroller é igual à altura do scroller
  sobre a altura de rolagem do ponto do scroller se e somente se o tamanho do ponto do estilo do ponto do polegar
  for igual à altura do scroller vezes a altura do scroller sobre a altura de rolagem do ponto do
  scroller.
<script>
    // …
    thumb.style.height =
    scrollerHeight * scrollerHeight / scroller.scrollHeight + 'px';
    // Accommodate for native scrollbars
    thumb.style.right =
    (scroller.clientWidth - scroller.getBoundingClientRect().width) + 'px';
</script>

O tamanho do polegar está bom, mas está se movendo muito rápido. É aqui que podemos usar nossa técnica do controle de paralaxe. Se movermos o elemento para trás, ele ficará mais lento durante a rolagem. Podemos corrigir o tamanho aumentando-o. Mas quanto exatamente devemos empurrá-lo de volta? Vamos fazer algumas contas. Prometo que é a última vez.

A informação crucial é que queremos que a borda inferior do polegar fique alinhada à borda inferior do elemento rolável quando rolado até o fim para baixo. Em outras palavras, se rolarmos scroller.scrollHeight - scroller.height pixels, queremos que o polegar seja movido por scroller.height - thumb.height. Para cada pixel do controle deslizante, queremos que o polegar se mova uma fração de um pixel:

O fator é igual à altura do ponto do scroller menos a altura do ponto do polegar sobre a altura
  do ponto do scroller menos a altura do ponto do scroller.

Esse é o nosso fator de escalonamento. Agora precisamos converter o fator de escalonamento em uma translação ao longo do eixo z, o que já fizemos no artigo sobre rolagem de paralaxe. De acordo com a seção relevante na especificação: o fator de dimensionamento é igual a p/(p - z). Podemos resolver essa equação para z para descobrir quanto precisamos traduzir nosso polegar ao longo do eixo z. Mas lembre-se que, devido às nossas artimanhas com as coordenadas w, precisamos traduzir mais uma -2px ao longo de z. Observe também que as transformações de um elemento são aplicadas da direita para a esquerda, o que significa que todas as traduções antes da matriz especial não serão invertidas. Todas as traduções após a matriz especial, no entanto, serão. Vamos codificar isso.

<script>
    // ... code from above...
    const factor =
    (scrollerHeight - thumbHeight)/(scroller.scrollHeight - scrollerHeight);
    thumb.style.transform = `
    matrix3d(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, -1
    )
    scale(${1/factor})
    translateZ(${1 - 1/factor}px)
    translateZ(-2px)
    `;
</script>

Temos uma barra de rolagem. E é apenas um elemento DOM que podemos estilizar como quisermos. Uma coisa que é importante fazer em termos de acessibilidade é fazer com que o polegar responda ao clique e arraste, já que muitos usuários estão acostumados a interagir com uma barra de rolagem dessa maneira. Para não deixar esta postagem do blog ainda mais longa, não vou explicar os detalhes dessa parte. Confira o código da biblioteca para saber como isso é feito.

E o iOS?

Ah, meu velho amigo Safari para iOS. Assim como na rolagem com paralaxe, encontramos um problema aqui. Como estamos rolando em um elemento, precisamos especificar -webkit-overflow-scrolling: touch, mas isso causa o achatamento 3D e todo o efeito de rolagem para de funcionar. Resolvemos esse problema no scroller de paralaxe detectando o Safari para iOS e usando position: sticky como solução alternativa. Vamos fazer exatamente a mesma coisa aqui. Consulte o artigo sobre paralaxe para refrescar sua memória.

E a barra de rolagem do navegador?

Em alguns sistemas, vamos ter que lidar com uma barra de rolagem nativa permanente. Historicamente, a barra de rolagem não pode ser oculta, exceto com um pseudoseletor não padrão. Para ocultá-lo, precisamos recorrer a algumas técnicas de hacker (sem matemática). Envolvemos nosso elemento de rolagem em um contêiner com overflow-x: hidden e ampliamos o elemento de rolagem para que ele fique mais largo que o contêiner. A barra de rolagem nativa do navegador agora está fora da tela.

Nadadeira

Juntando tudo, agora podemos criar uma barra de rolagem personalizada perfeita, como a da demonstração do Nyan Cat.

Se você não conseguir ver o Nyan Cat, você está enfrentando um bug que encontramos e registramos ao criar essa demonstração (clique no polegar para fazer o Nyan Cat aparecer). O Chrome é muito bom em evitar trabalhos desnecessários, como pintar ou animar elementos fora da tela. A má notícia é que nossas manobras de matriz fazem o Chrome pensar que o GIF do Nyan Cat está fora da tela. Esperamos que isso seja corrigido em breve.

É isso. Isso exigiu muito trabalho. Parabéns por ler tudo. Isso é uma pegadinha para fazer isso funcionar, e provavelmente vale a pena o esforço, exceto quando uma barra de rolagem personalizada é uma parte essencial da experiência. Mas é bom saber que isso é possível, não é? O fato de ser tão difícil fazer uma barra de rolagem personalizada mostra que há trabalho a ser feito no lado do CSS. Mas não se preocupe. No futuro, o AnimationWorklet do Houdini vai facilitar muito efeitos de links de rolagem perfeitos, como esse.