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, o que se deve principalmente ao fato de que as barras de rolagem são um dos trechos restantes na Web que são praticamente destiláveis (este é o seletor de data). Você pode usar o JavaScript para criar o seu, mas isso é caro, tem baixa fidelidade e pode parecer lento. Neste artigo, vamos usar algumas matrizes CSS não convencionais para criar um botão de rolagem personalizado que não requer nenhum JavaScript durante a rolagem, apenas um pouco do 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 (em inglês) e fazer o download da biblioteca? Veja o código da demonstração no nosso repositório do GitHub (link em inglês).

LAM;WRA (longo e matemático; vai ser lido mesmo assim)

Há algum tempo, criamos um botão de rolagem de paralaxe. Você leu este artigo? É muito bom e vale a pena! Ao empurrar os elementos de volta usando transformações CSS 3D, eles 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, alcançamos o efeito paralaxe ao empurrar os elementos para trás no espaço 3D, ao longo do eixo Z. A rolagem de um documento é, efetivamente, uma tradução ao longo do eixo Y. Portanto, se rolarmos para baixo em 100 px, todos os elementos serão convertidos para cima em 100 px. Isso se aplica a todos os elementos, mesmo os que estão "mais atrás". 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 para o espaço também faz com que ele pareça menor, o que corrigimos redimensionando o elemento de volta para cima. Descobrimos a matemática exata quando criamos o rolador 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 fiz isso. As barras de rolagem indicam quanto do conteúdo disponível está visível no momento e quanto progresso você, como leitor, fez. Se você rolar para baixo, a barra de rolagem indicará que você está progredindo em direção ao fim. Se todo o conteúdo couber na janela de visualização, a barra de rolagem geralmente fica 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. Um conteúdo que vale três vezes a altura da janela de visualização dimensiona a barra de rolagem para 1⁄3 da janela etc. Você vê 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 imperceptível como esse. Vamos lutar uma batalha de cada vez.

Etapa 1: ao contrário

É 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. Podemos inverter a direção? Acontece que sim, e essa é nossa maneira de criar uma barra de rolagem personalizada e perfeita para frames. Para entender como isso funciona, precisamos cobrir algumas noções básicas de CSS 3D primeiro.

Para ter qualquer tipo de projeção de perspectiva em termos matemáticos, você provavelmente acabará usando coordenadas homogêneas. Não entrarei em detalhes sobre o que 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, exceto se você quiser distorção de perspectiva. Não precisamos nos preocupar com os detalhes de w, já que não usaremos nenhum outro valor além de 1. Portanto, a partir de agora, todos os pontos são vetores de quatro dimensões [x, y, z, w=1]. Consequentemente, as matrizes também precisam ser 4x4.

Uma ocasião em que é possível observar 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. Portanto, podemos usar essa função para especificar manualmente rotações, traduções etc. Mas o que ela também nos permite mexer na coordenada w.

Antes de usarmos o matrix3d(), precisamos de um contexto 3D, porque sem ele não haveria distorção de perspectiva nem a necessidade de 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 translação ao longo do eixo Y. Se rolarmos para baixo por 400 px, todos os elementos precisarão ser movidos em 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 causa os dois efeitos de fazer as coisas parecerem menores quando estão mais distantes e também faz com que elas “avancem mais devagar” durante a tradução. 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 spec sobre o modelo de renderização de transformação do CSS. Porém, para este artigo, simplifiquei o algoritmo acima.

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

Matriz de perspectiva vezes a matriz de rolagem vezes a matriz de transformação do elemento

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

No entanto, para nossa barra de rolagem, queremos o oposto – queremos que o elemento se mova para baixo quando estivermos rolando para baixo. Veja onde podemos usar um truque: invertendo a coordenada w dos cantos da caixa. Se a coordenada w for -1, todas as traduções entrarão em vigor na direção oposta. Como fazer isso? O mecanismo CSS realiza a conversão dos cantos do box 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 fará nada mais que negar w. Portanto, quando o mecanismo CSS transforma cada canto em um vetor com o formato [x,y,z,1], a matriz o converte em [x,y,z,-1].

matriz de identidade 4 por 4 com menos um sobre p na quarta linha

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

No entanto, se colocarmos essa matriz apenas em nosso exemplo, o elemento não será exibido. Isso ocorre porque a especificação CSS exige que qualquer vértice com w < 0 bloqueie a renderização do elemento. Como no momento a coordenada Z é 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: faça a mudança

Agora nossa caixa está lá e tem a mesma aparência que teria sem nenhuma transformação. No momento, não é possível ver o contêiner de perspectiva, mas sabemos que nosso elemento seguirá para a outra direção quando rolado. Então, vamos fazer o contêiner rolar, certo? Basta adicionar um elemento espaçador que ocupe 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: dar um tamanho

Temos um elemento que se move para baixo quando a página rola para baixo. Essa é a parte difícil. Agora precisamos estilizá-la para parecer uma barra de rolagem e torná-la um pouco mais interativa.

Uma barra de rolagem geralmente consiste em um "dedo" e uma "faixa", mas 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 visível. A proporção do espaço vertical que as capas de dedos precisa ser igual à proporção de conteúdo visível:

apenas se a altura do ponto estilo do ponto do polegar do ponto for igual à altura do botão de rolagem vezes a altura do botão sobre a altura de rolagem do ponto de rolagem.
<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 parece bom, mas está se movendo muito rápido. É aqui que podemos usar nossa técnica no botão de rolagem de paralaxe. Se movermos o elemento para trás, ele ficará mais lento durante a rolagem. Podemos corrigir o tamanho aumentando-o. Mas até que ponto devemos recolher exatamente isso? Vamos fazer uma parte, você adivinhou, matemática! Essa é a última vez, eu prometo.

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

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

Esse é o nosso fator de escalonamento. Agora, precisamos converter o fator de dimensionamento 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 dimensionamento é igual a p/(p - z). Podemos resolver essa equação de z para descobrir quanto precisamos para converter nosso polegar ao longo do eixo z. Mas lembre-se de que, devido aos nossos manobras das coordenadas w, precisamos traduzir um -2px adicional para 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. No entanto, todas as traduções após a matriz especial 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 quiser. Uma coisa que é importante fazer em termos de acessibilidade é fazer o polegar responder ao clicar e arrastar, já que muitos usuários estão acostumados a interagir com uma barra de rolagem dessa maneira. Para não tornar 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 iOS Safari. 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 achatamento 3D e todo o efeito de rolagem deixa de funcionar. Resolvemos esse problema no botão de rolagem de paralaxe detectando o iOS Safari e contando com position: sticky como solução alternativa. Vamos fazer exatamente o mesmo aqui. Confira o artigo sobre paralaxe para refrescar sua memória.

E a barra de rolagem do navegador?

Em alguns sistemas, será necessário usar uma barra de rolagem nativa e permanente. Historicamente, a barra de rolagem não pode ficar oculta, exceto com um pseudoseletor não padrão. Então, para ocultá-lo, 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 isso, agora podemos criar uma barra de rolagem personalizada perfeita, como a da nossa demonstração do gato Nyan.

Se você não encontrar o gato Nyan, significa que está enfrentando um bug que encontramos e registramos durante a criação desta demonstração. Clique no ícone de miniatura para que o gato Nyan apareça. O Chrome é bom em evitar trabalhos desnecessários, como pintura ou animação de coisas que estão fora da tela. A má notícia é que nossos manobras matriciais fazem o Chrome pensar que o gif do gato Nyan está realmente fora da tela. Esperamos que o problema seja corrigido em breve.

Aí está. Foi muito trabalho. Agradeço por você ter lido tudo. Esses são alguns truques reais para que isso funcione e provavelmente raramente valem 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 difícil criar uma barra de rolagem personalizada mostra que há trabalho a ser feito no CSS. Mas não se preocupe! No futuro, o AnimationWorklet do Houdini vai facilitar muito efeitos de links de rolagem perfeitos, como esse.