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, e isso se deve principalmente ao fato de que as barras de rolagem são uma das partes restantes na Web que não têm estilo (estou olhando para você, seletor de data). Você pode usar o JavaScript para criar o seu, mas isso é caro, de baixa fidelidade e pode parecer lento. Neste artigo, usaremos algumas matrizes CSS não convencionais para criar um botão de rolagem personalizado que não exija JavaScript durante a rolagem, apenas algum código de configuração.

Texto longo, leia o resumo

Você não se importa com as pequenas coisas? Você quer apenas ver a demonstração do gato Nyan e obter a biblioteca? O código da demonstração está no nosso repositório do GitHub (link em inglês).

LAM;WRA (Longo e matemático; lerá mesmo assim)

Um tempo atrás, criamos um botão de rolagem paralaxe. Você leu este artigo? É muito bom e vale a pena!). Ao devolver elementos usando transformações CSS 3D, eles se moveram mais devagar do que a velocidade real de rolagem.

Recap

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

Como mostrado na animação, alcançamos o efeito paralaxe empurrando os elementos para trás no espaço 3D, ao longo do eixo Z. Rolar um documento é efetivamente uma translação ao longo do eixo Y. Portanto, se rolarmos para baixo, digamos 100 px, cada elemento vai ser traduzido para cima em 100 px. Isso se aplica a todos os elementos, até mesmo os que estão "mais longe". No entanto, como eles estão mais longe da câmera, o movimento observado na tela será menor que 100 px, produzindo o efeito de paralaxe desejado.

Mover um elemento de volta ao espaço também faz com que ele pareça menor, o que corrigimos quando redimensionamos o elemento. Como descobrimos o cálculo exato quando criamos o rolador de paralaxe, não vou repetir todos os detalhes.

Etapa 0: o que queremos fazer?

Barras de rolagem. Isso é o que vamos criar. Mas você já pensou no que eles fazem? Claro que não fui. As barras de rolagem são um indicador de quanto do conteúdo disponível está visível no momento e quanto progresso você, como o leitor, fez. Se você rolar para baixo, a barra de rolagem também vai indicar que você está progredindo até o fim. Se todo o conteúdo couber na janela de visualização, a barra de rolagem normalmente ficará oculta. Se o conteúdo tiver o dobro da altura da janela de visualização, a barra de rolagem preencherá 1⁄2 da altura dela. Conteúdo com o triplo da altura da janela de visualização redimensiona a barra de rolagem para 1⁄3 da janela etc. Você vê o padrão. Em vez de rolar a tela, 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: colocá-lo em ordem inversa

É possível fazer com que os elementos se movam mais lentamente do que a velocidade de rolagem com transformações CSS 3D, conforme descrito no artigo sobre rolagem paralaxe. Também podemos inverter a direção? E esse é nosso caminho para criar uma barra de rolagem personalizada e perfeita. Para entender como isso funciona, precisamos abordar alguns fundamentos do CSS 3D.

Para ter qualquer tipo de projeção de perspectiva no sentido matemático, é provável que você acabe usando coordenadas homogêneas. Não vou entrar em detalhes sobre o que são e por que funcionam, mas podem ser vistos como coordenadas 3D com uma quarta coordenada adicional chamada w. Essa coordenada precisa ser 1, exceto se você quiser distorção de perspectiva. Não precisamos nos preocupar com os detalhes de w, já que não usaremos nenhum valor diferente de 1. Portanto, a partir de agora, todos os pontos são vetores quadridimensionais [x, y, z, w=1] e, consequentemente, as matrizes também precisam ser 4x4.

Uma ocasião em que é possível notar que o CSS usa coordenadas homogêneas em segundo plano é 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. Assim, podemos usar essa função para especificar manualmente rotações, translações etc. Mas o que ela também nos permite fazer é mudar a coordenada w.

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

Uma parte do 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 CSS da seguinte maneira:

  • Transforma cada canto (vértice) de um elemento em coordenadas homogêneas [x,y,z,w] 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 translação ao longo do eixo Y. Se rolar para baixo em 400 px, todos os elementos precisarão ser movidos para cima em 400 px. A matriz de perspectiva é aquela que "puxa" os pontos para mais perto do ponto de fuga quanto mais longe eles estiverem no espaço 3D. Isso gera os efeitos de fazer as coisas parecerem menores quando estiverem mais longe e também faz com que elas "movam-se mais devagar" durante a tradução. Portanto, se um elemento for adiado, uma translação de 400 pixels vai fazer com que ele se mova apenas 300 pixels na tela.

Se você quiser saber todos os detalhes, leia a spec do 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 valor p para o atributo perspective, e vamos supor que o contêiner seja rolável e rolado para baixo em n pixels.

Matriz de perspectiva vezes matriz de rolagem vezes matriz de transformação de elemento igual a quatro por quatro matriz de identidade com menos um sobre p na quarta linha da terceira coluna vezes quatro por quatro matriz de identidade com menos n na segunda linha e quarta coluna vezes a matriz de transformação do elemento.

A primeira é a matriz de perspectiva, e a segunda é a de rolagem. Recapitulando: a função da matriz de rolagem é fazer um elemento se mover para cima quando estamos rolando para baixo, por isso o sinal negativo.

No entanto, para a barra de rolagem, queremos o oposto: queremos que o elemento se mova para baixo quando rolarmos para baixo. Aqui é onde podemos usar um truque: inverter a coordenada w dos cantos da nossa caixa. Se a coordenada w for -1, todas as traduções terão efeito na direção oposta. Como fazemos isso? O mecanismo do CSS se encarrega de converter os cantos da caixa em coordenadas homogêneas e define "w" como 1. Chegou a 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 fará nada além de negar w. Assim, quando o mecanismo CSS tiver transformado cada canto em um vetor da forma [x,y,z,1], a matriz o converterá em [x,y,z,-1].

matriz de identidade 4 por 4 com menos um sobre p na quarta linha, terceira coluna vezes quatro por quatro linhas de matriz de identidade com menos n na segunda linha, quarta coluna vezes 4 por quatro matriz de identidade com menos um na quarta linha vezes p.

Listei uma etapa intermediária para mostrar o efeito da nossa matriz de transformação de elementos. Se você não estiver confortável com matemática matricial, tudo bem. O momento de Eureka é que, na última linha, acabamos adicionando o deslocamento de rolagem n à coordenada y, em vez de subtraí-lo. O elemento será convertido para baixo se rolarmos para baixo.

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

Felizmente, podemos escolher o valor de z. Para garantir que vamos terminar 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);
}

Veja só, nossa caixa está de volta!

Etapa 2: mova o app

Agora nossa caixa está lá e está com a mesma aparência que teria sem nenhuma transformação. No momento, o contêiner da perspectiva não é rolável, então não podemos vê-lo, mas sabemos que nosso elemento seguirá outra direção quando rolado. Vamos fazer o contêiner rolar, certo? Podemos apenas adicionar um elemento espaçador 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 um tamanho

Temos um elemento que se move para baixo quando a página rola para baixo. Essa é a parte difícil, na verdade. Agora, precisamos estilizá-lo para que se pareça com uma barra de rolagem e torná-lo um pouco mais interativo.

Uma barra de rolagem geralmente consiste em um "círculo" e uma "faixa", mas a faixa nem sempre está visível. A altura do círculo é 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 visível. A proporção do espaço vertical que o círculo cobre precisa ser igual à proporção do conteúdo visível:

estilo de ponto do polegar para a altura do ponto sobre scrollerHeight igual à altura do botão
  sobre a altura de rolagem dos pontos se e somente se a altura do ponto para o estilo de polegar
  for igual à altura do botão vezes a altura do botão vezes a altura do botão sobre a altura de
  rolagem dos pontos.
<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á parecendo bom, mas está se movendo muito rápido. É aqui que podemos usar nossa técnica do botão de rolagem paralaxe. Se o elemento for movido para trás, o movimento vai ficar mais lento durante a rolagem. É possível corrigir o tamanho aumentando-o. Mas quanto devemos adiar exatamente? Como você adivinhou, vamos fazer matemática! Prometo que é a última vez.

A informação crucial é que queremos que a borda inferior do polegar se alinhe à borda inferior do elemento rolável quando o usuário rolar a tela para baixo. Em outras palavras: se rolamos scroller.scrollHeight - scroller.height pixels, queremos que nosso círculo seja traduzido por scroller.height - thumb.height. Para cada pixel do botão de rolagem, queremos que o polegar se mova uma fração de pixel:

O fator é igual à altura do ponto do botão de rolagem menos a altura do ponto do polegar sobre a altura de rolagem dos
 pontos de rolagem menos a altura do ponto do botão de rolagem.

Esse é nosso fator de escala. Agora precisamos converter o fator de escalonamento em uma translação ao longo do eixo z, o que já fizemos no artigo sobre rolagem paralaxe. De acordo com a seção relevante na especificação: o fator de escalonamento é igual a p/(p − z). Podemos resolver essa equação para z e descobrir quanto precisamos traduzir nosso polegar ao longo do eixo z. Porém, lembre-se de que, devido aos nossos truques de coordenadas w, precisamos traduzir uma -2px adicional ao longo de z. Além disso, as transformações de um elemento são aplicadas da direita para a esquerda, o que significa que todas as traduções antes da nossa matriz especial não serão invertidas, mas 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. Além disso, esse é apenas um elemento DOM que podemos estilizar como quisermos. Uma coisa importante em termos de acessibilidade é fazer com que o polegar responda a clicar e arrastar, já que muitos usuários estão acostumados a interagir com uma barra de rolagem dessa forma. Para não deixar esta postagem do blog mais longa, não vou explicar os detalhes dessa parte. Confira o código da biblioteca para ver detalhes se quiser ver como isso é feito.

E o iOS?

Ah, meu velho amigo Safari do iOS. Assim como na rolagem paralaxe, encontramos um problema aqui. Como estamos rolando em um elemento, precisamos especificar -webkit-overflow-scrolling: touch, mas isso causa o nivelamento de 3D, e todo o efeito de rolagem para de funcionar. Resolvemos esse problema no botão de rolagem paralaxe detectando o iOS Safari e usando position: sticky como solução alternativa, e vamos fazer exatamente o mesmo aqui. Confira o artigo sobre paralaxe para relembrar alguns conceitos.

E a barra de rolagem do navegador?

Em alguns sistemas, será preciso usar uma barra de rolagem nativa permanente. Historicamente, a barra de rolagem não pode ficar oculta, exceto com um pseudoseletor não padrão. Para ocultar isso, temos que recorrer a alguns hackers (sem matemática). Unimos nosso elemento de rolagem em um contêiner com overflow-x: hidden e o tornamos mais largo que o contêiner. A barra de rolagem nativa do navegador agora está fora da visualização.

Barbatana

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

Se não encontrar o Nyan cat, isso significa que você está enfrentando um bug que encontramos e registramos ao criar esta demonstração (clique no polegar para fazer Nyan cat aparecer). O Chrome é muito bom em evitar trabalhos desnecessários como pintar ou animar coisas que estão fora da tela. A má notícia é que nossos manobras de matriz fazem o Chrome pensar que o gif do gato Nyan está realmente fora da tela. Esperamos que isso seja corrigido em breve.

Aí está. Foi muito trabalho. Agradeço por ter lido tudo. Esse é um truque real para fazer isso funcionar e provavelmente raramente vale 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 tem problema! No futuro, o AnimationWorklet do Houdini vai facilitar muito mais efeitos de link de rolagem perfeitos para frames como esse.