Paralaxe com performance

Goste ou não, o efeito de paralaxe veio para ficar. Quando usado com moderação, ele pode adicionar profundidade e sutileza a um web app. No entanto, implementar o efeito de paralaxe de maneira eficiente pode ser um desafio. Neste artigo, vamos discutir uma solução eficiente e, igualmente importante, que funciona em vários navegadores.

Ilustração de paralaxe.

TL;DR

  • Não use eventos de rolagem ou background-position para criar animações de paralaxe.
  • Use transformações 3D do CSS para criar um efeito de paralaxe mais preciso.
  • Para o Safari em dispositivos móveis, use position: sticky para garantir que o efeito de paralaxe seja propagado.

Se quiser a solução pronta para uso, acesse o repositório do GitHub de amostras de elementos da interface e pegue o JS auxiliar de efeito de paralaxe. Confira uma demonstração ao vivo do rolador de paralaxe no repositório do GitHub.

Paralaxers de problemas

Para começar, vamos analisar duas maneiras comuns de conseguir um efeito de paralaxe e, em particular, por que elas não são adequadas para nossos propósitos.

Incorreto: usar eventos de rolagem

O requisito principal do efeito paralaxe é que ele seja acoplado à rolagem. Para cada mudança na posição de rolagem da página, a posição do elemento de efeito paralaxe precisa ser atualizada. Embora pareça simples, um mecanismo importante dos navegadores modernos é a capacidade de trabalhar de forma assíncrona. No nosso caso específico, isso se aplica a eventos de rolagem. Na maioria dos navegadores, os eventos de rolagem são entregues como "melhor esforço" e não há garantia de que serão entregues em todos os frames da animação de rolagem.

Essa informação importante nos diz por que precisamos evitar uma solução baseada em JavaScript que move elementos com base em eventos de rolagem: o JavaScript não garante que o efeito de paralaxe acompanhe a posição de rolagem da página. Em versões mais antigas do Safari para dispositivos móveis, os eventos de rolagem eram entregues no final da rolagem, o que impossibilitava a criação de um efeito de rolagem baseado em JavaScript. Versões mais recentes fazem a entrega de eventos de rolagem durante a animação, mas, assim como o Chrome, com base no "melhor esforço". Se a thread principal estiver ocupada com qualquer outro trabalho, os eventos de rolagem não serão entregues imediatamente, o que significa que o efeito de paralaxe será perdido.

Incorreto: atualizando background-position

Outra situação que queremos evitar é a pintura em todos os frames. Muitas soluções tentam mudar background-position para fornecer o efeito de paralaxe, o que faz com que o navegador pinte novamente as partes afetadas da página ao rolar. Isso pode ser caro o suficiente para prejudicar significativamente a animação.

Se quisermos cumprir a promessa de movimento de paralaxe, precisamos de algo que possa ser aplicado como uma propriedade acelerada (o que hoje significa usar transformações e opacidade) e que não dependa de eventos de rolagem.

CSS em 3D

Scott Kellum e Keith Clark fizeram um trabalho significativo na área de uso do CSS 3D para alcançar o movimento de paralaxe. A técnica usada por eles é esta:

  • Configure um elemento de contêiner para rolar com overflow-y: scroll (e provavelmente overflow-x: hidden).
  • Aplique a esse mesmo elemento um valor perspective e um perspective-origin definido como top left ou 0 0.
  • Aplique uma translação em Z aos filhos desse elemento e redimensione-os para cima para fornecer movimento de paralaxe sem afetar o tamanho deles na tela.

O CSS para essa abordagem é assim:

.container {
  width: 100%;
  height: 100%;
  overflow-x: hidden;
  overflow-y: scroll;
  perspective: 1px;
  perspective-origin: 0 0;
}

.parallax-child {
  transform-origin: 0 0;
  transform: translateZ(-2px) scale(3);
}

que pressupõe um snippet de HTML como este:

<div class="container">
    <div class="parallax-child"></div>
</div>

Ajustar a escala para a perspectiva

Ao empurrar o elemento filho para trás, ele vai diminuir proporcionalmente ao valor da perspectiva. É possível calcular o quanto ele precisará ser ampliado com esta equação: (perspectiva - distância) / perspectiva. Como provavelmente queremos que o elemento de paralaxe tenha esse efeito, mas apareça no tamanho em que foi criado, ele precisa ser ampliado dessa forma, em vez de ser deixado como está.

No caso do código acima, a perspectiva é 1px, e a distância Z do parallax-child é -2px. Isso significa que o elemento precisará ser ampliado em 3x, que é o valor inserido no código: scale(3).

Para qualquer conteúdo que não tenha um valor translateZ aplicado, você pode substituir por um valor zero. Isso significa que a escala é (perspectiva - 0) / perspectiva, que resulta em um valor de 1, o que significa que ela não foi aumentada nem diminuída. É muito útil.

Como essa abordagem funciona

É importante deixar claro por que isso funciona, já que vamos usar esse conhecimento em breve. A rolagem é uma transformação, por isso pode ser acelerada. Ela envolve principalmente a movimentação de camadas com a GPU. Em uma rolagem típica, que não tem noção de perspectiva, a rolagem acontece de maneira 1:1 ao comparar o elemento de rolagem e os filhos dele. Se você rolar um elemento para baixo em 300px, os filhos dele serão transformados para cima pela mesma quantidade: 300px.

No entanto, aplicar um valor de perspectiva ao elemento de rolagem atrapalha esse processo, mudando as matrizes que sustentam a transformação de rolagem. Agora, uma rolagem de 300 px pode mover os filhos em apenas 150 px, dependendo dos valores de perspective e translateZ escolhidos. Se um elemento tiver um valor translateZ de 0, ele será rolado em 1:1 (como era antes), mas um filho empurrado em Z para longe da origem da perspectiva será rolado em uma taxa diferente. Resultado líquido: movimento de paralaxe. E, muito importante, isso é processado automaticamente como parte do mecanismo de rolagem interno do navegador. Ou seja, não é necessário detectar eventos scroll nem mudar background-position.

Um problema: Safari para dispositivos móveis

Há ressalvas para cada efeito, e uma importante para transformações é sobre a preservação de efeitos 3D para elementos filhos. Se houver elementos na hierarquia entre o elemento com uma perspectiva e os filhos com efeito de paralaxe, a perspectiva 3D será "achatada", ou seja, o efeito será perdido.

<div class="container">
    <div class="parallax-container">
    <div class="parallax-child"></div>
    </div>
</div>

No HTML acima, o .parallax-container é novo e vai efetivamente achatar o valor perspective, e perdemos o efeito de paralaxe. A solução, na maioria dos casos, é bem simples: adicione transform-style: preserve-3d ao elemento, fazendo com que ele propague todos os efeitos 3D (como nosso valor de perspectiva) que foram aplicados mais acima na árvore.

.parallax-container {
  transform-style: preserve-3d;
}

No entanto, no caso do Safari para dispositivos móveis, as coisas são um pouco mais complicadas. Aplicar overflow-y: scroll ao elemento contêiner funciona tecnicamente, mas ao custo de não poder mover o elemento de rolagem. A solução é adicionar -webkit-overflow-scrolling: touch, mas isso também vai achatar o perspective e não teremos efeito de paralaxe.

Do ponto de vista do aprimoramento progressivo, isso provavelmente não é um problema muito grande. Se não for possível usar o efeito de paralaxe em todas as situações, o app ainda vai funcionar, mas seria bom encontrar uma solução alternativa.

position: sticky para ajudar!

Na verdade, há uma ajuda na forma de position: sticky, que existe para permitir que os elementos "fiquem" na parte superior da janela de visualização ou em um determinado elemento pai durante a rolagem. A especificação, como a maioria delas, é bastante extensa, mas contém uma pequena joia útil:

Isso pode não parecer muito à primeira vista, mas um ponto importante nessa frase é quando ela se refere a como, exatamente, a fixação de um elemento é calculada: "o deslocamento é calculado com referência ao ancestral mais próximo com uma caixa de rolagem". Em outras palavras, a distância para mover o elemento fixo (para que ele apareça anexado a outro elemento ou à janela de visualização) é calculada antes de qualquer outra transformação ser aplicada, e não depois. Isso significa que, assim como no exemplo de rolagem anterior, se o deslocamento foi calculado em 300 px, há uma nova oportunidade de usar perspectivas (ou qualquer outra transformação) para manipular esse valor de deslocamento de 300 px antes que ele seja aplicado a elementos fixos.

Ao aplicar position: -webkit-sticky ao elemento de paralaxe, podemos "reverter" o efeito de achatamento de -webkit-overflow-scrolling: touch. Isso garante que o elemento de paralaxe faça referência ao ancestral mais próximo com uma caixa de rolagem, que, nesse caso, é .container. Assim como antes, o .parallax-container aplica um valor perspective, que muda o deslocamento de rolagem calculado e cria um efeito de paralaxe.

<div class="container">
    <div class="parallax-container">
    <div class="parallax-child"></div>
    </div>
</div>
.container {
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
}

.parallax-container {
  perspective: 1px;
}

.parallax-child {
  position: -webkit-sticky;
  top: 0px;
  transform: translate(-2px) scale(3);
}

Isso restaura o efeito de paralaxe para o Safari para dispositivos móveis, o que é uma ótima notícia!

Advertências sobre o posicionamento fixo

No entanto, há uma diferença: position: sticky altera a mecânica de paralaxe. O posicionamento fixo tenta, bem, fixar o elemento ao contêiner de rolagem, enquanto uma versão não fixa não faz isso. Isso significa que o efeito de paralaxe com fixação acaba sendo o inverso do que não tem:

  • Com position: sticky, quanto mais próximo o elemento estiver de z=0, menos ele se moverá.
  • Sem position: sticky, quanto mais perto o elemento estiver de z=0, mais ele se moverá.

Se tudo isso parece um pouco abstrato, confira esta demonstração (link em inglês) de Robert Flack, que mostra como os elementos se comportam de maneira diferente com e sem posicionamento fixo. Para ver a diferença, você precisa do Chrome Canary (que é a versão 56 no momento da redação) ou do Safari.

Captura de tela com perspectiva de paralaxe

Uma demonstração de Robert Flack mostrando como position: sticky afeta a rolagem paralaxe.

Vários bugs e soluções alternativas

No entanto, como em qualquer coisa, ainda há problemas que precisam ser resolvidos:

  • O suporte fixo é inconsistente. A compatibilidade ainda está sendo implementada no Chrome, o Edge não tem suporte e o Firefox tem bugs de renderização quando o sticky é combinado com transformações de perspectiva. Nesses casos, vale a pena adicionar um pouco de código para incluir apenas position: sticky (a versão com prefixo -webkit-) quando necessário, ou seja, apenas para o Safari para dispositivos móveis.
  • O efeito não "simplesmente funciona" no Edge. O Edge tenta processar a rolagem no nível do SO, o que geralmente é bom, mas, nesse caso, impede que ele detecte as mudanças de perspectiva durante a rolagem. Para corrigir isso, adicione um elemento de posição fixa, já que isso parece mudar o Edge para um método de rolagem não relacionado ao SO, e garante que ele considere as mudanças de perspectiva.
  • "O conteúdo da página ficou enorme!" Muitos navegadores consideram a escala ao decidir o tamanho do conteúdo da página, mas, infelizmente, o Chrome e o Safari não consideram a perspectiva. Portanto, se houver, por exemplo, uma escala de 3x aplicada a um elemento, talvez você veja barras de rolagem e semelhantes, mesmo que o elemento esteja em 1x depois que o perspective for aplicado. É possível contornar esse problema dimensionando elementos do canto inferior direito (com transform-origin: bottom right), o que funciona porque faz com que elementos grandes demais cresçam na "região negativa" (normalmente a parte superior esquerda) da área rolável. As regiões roláveis nunca permitem ver ou rolar até o conteúdo na região negativa.

Conclusão

O efeito de paralaxe é divertido quando usado com cuidado. Como você pode ver, é possível implementá-lo de uma forma eficiente, acoplada à rolagem e compatível com vários navegadores. Como isso exige um pouco de matemática e uma pequena quantidade de boilerplate para alcançar o efeito desejado, criamos uma pequena biblioteca auxiliar e um exemplo, que podem ser encontrados no repositório do GitHub de exemplos de elementos da interface.

Teste e conte para nós como foi.