Novo Livro!
Categorias
Projetos
CSS

Como usar scroll-driven animations em direções opostas

Como usar scroll-driven animations em direções opostas

Colunas de itens que deslizam em direções opostas conforme a página é rolada, desaparecendo suavemente ao cruzar as bordas do container. Parece trabalho para uma biblioteca JavaScript, mas as scroll-driven animations entregam o efeito inteiro com CSS puro e bem menos código do que se imagina.

Se não estiver vendo rolagem vertical (ex: está em dispositivo móvel), abra o pen em nova aba.

Às vezes, designers têm ideias meio bobas que acabam conquistando quem precisa implementá-las. Não é tão difícil quanto parece, graças a recursos modernos do CSS, especificamente as scroll-driven animations.

Se os conceitos básicos de @keyframes e animation ainda forem novidade, vale começar pela introdução a animações CSS.

O HTML

O HTML é formado por um elemento pai (.opposing-columns), seus filhos (.opposing-column) e os filhos desses filhos (.opposing-item):

<div class="opposing-columns">
<!-- Coluna 1 -->
<div class="opposing-column">
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
</div>
<!-- Coluna 2 -->
<div class="opposing-column">
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
</div>
<!-- Coluna 3 -->
<div class="opposing-column">
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
<div class="opposing-item">...</div>
</div>
</div>

É tudo o que a marcação precisa. O CSS faz o resto!

Estilizando o container pai

Para começar, o efeito será aplicado apenas em telas maiores. Não faz muito sentido dar suporte a algo assim em telas pequenas, já que o espaço extra é justamente o que torna o efeito possível.

/* Somente em telas maiores */
@media screen and (width >= 50rem) {
.opposing-columns {
display: flex;
gap: 2rem;
max-inline-size: min(90dvi, 50rem);
margin-inline: auto;
}
}

Montando o efeito de “máscara”

Ainda faltam alguns ajustes no container pai para criar a ilusão de que os itens de cada .opposing-column desaparecem ao passar por ele. Os itens das colunas externas sobem durante a rolagem e os da coluna central descem. Ao cruzar os limites do elemento pai, eles precisam sumir gradualmente.

O primeiro passo é definir uma variável de cor de fundo no documento inteiro:

@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* mesmos estilos de antes */
}
}

O segundo passo é aplicar essa mesma cor de fundo nos pseudo-elementos :before e :after do elemento pai:

@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* mesmos estilos de antes */
&:before,
&:after {
content: "";
position: absolute;
inset-inline: 0;
block-size: calc(var(--opposing-mask) * 3);
pointer-events: none;
z-index: 1;
}
}
}

Repare que foi estabelecido um contexto de empilhamento nos pseudo-elementos, posicionando-os uma camada acima do pai e de seus descendentes. Esse é o ponto-chave para mascarar os itens de cada coluna quando eles entram e saem do container: tecnicamente, os itens deslizam por baixo das máscaras.

Falando nisso, é hora de criar outra variável, --opposing-mask, que adiciona espaço vertical entre o elemento pai e as três colunas:

@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
background-color: var(--opposing-bg);
}
.opposing-columns {
display: flex;
gap: 2rem;
max-inline-size: min(90dvi, 50rem);
margin-inline: auto;
margin-block: var(--opposing-mask, 3rem);
position: relative;
}
}
Destaque do espaço vertical entre o container pai e seus elementos filhos.

O mesmo vale para os pseudo-elementos do pai, só que aplicando --opposing-mask ao block-size multiplicado por três. Assim, existe espaço vertical adicional entre eles e o elemento pai.

@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
background-color: var(--opposing-bg);
}
.opposing-columns {
/* mesmos estilos de antes */
&:before,
&:after {
content: "";
position: absolute;
inset-inline: 0;
block-size: calc(var(--opposing-mask) * 3);
pointer-events: none;
z-index: 1;
}
}
}
Destaque do espaço vertical entre o container pai e seu pseudo-elemento before.

Talvez já dê para perceber aonde isso vai chegar. Existe um bom espaço entre o container pai e seus pseudo-elementos, e a ideia é que os itens das colunas pareçam desaparecer gradualmente ao sair do container.

Nada disso exige mexer em opacity ou coisa parecida. Basta aplicar gradientes de fundo nos pseudo-elementos.

O pseudo :before fica no topo do container, então recebe um gradiente que vai de uma cor sólida (igual à cor de fundo do documento) até transparente, de cima para baixo. Como o :after fica na base do container pai, o gradiente é invertido: de transparente até a cor de fundo do documento, de baixo para cima.

@media screen and (width >= 50rem) {
:root {
/* mesmos estilos de antes */
}
.opposing-columns {
/* mesmos estilos de antes */
&:before,
&:after {
/* mesmos estilos de antes */
}
&:before {
background-image: linear-gradient(
to bottom,
var(--opposing-bg) var(--opposing-mask),
transparent
);
inset-block-start: calc(var(--opposing-mask) * -1);
}
&:after {
background-image: linear-gradient(
to top,
var(--opposing-bg) var(--opposing-mask),
transparent
);
inset-block-end: calc(var(--opposing-mask) * -1);
}
}
}
}

O layout das colunas

Antes da mágica, é preciso organizar os itens de cada coluna. Cada coluna é um flex item dentro do elemento pai, que é um flex container. Elas podem encolher (flex-shrink: 1) e crescer (flex-grow: 1), com o tamanho limitado a um certo ponto (flex-basis: 10rem).

Tudo isso cabe na propriedade abreviada flex:

@media screen and (width >= 50rem) {
/* mesmos estilos de antes */
.opposing-column {
flex: 1 1 10rem;
}
}

Agora, essas colunas precisam ser grid containers, para que a propriedade gap insira espaço entre os itens:

@media screen and (width >= 50rem) {
/* mesmos estilos de antes */
.opposing-column {
flex: 1 1 10rem;
display: grid;
gap: 2rem;
}
}

Também seria possível usar Flexbox aqui para ter acesso ao gap, mas o layout padrão é row e seria necessário sobrescrevê-lo para column. Nessa situação, o Grid é um pouco mais conciso.

A animação

É por isso que você chegou até aqui, certo? Tudo está montado para que os itens das colunas entrem e saiam do container pai durante a rolagem. Agora falta adicionar esse comportamento de rolagem.

É aqui que a propriedade animation-timeline entra em cena. Normalmente, uma animação CSS roda sozinha: começa quando a página carrega (ou após um delay específico) e termina depois da duração definida.

Com animation-timeline, a animação passa a rodar com base na posição de rolagem, daí o termo “scroll-driven”.

Há 2 funções com suporte: scroll() e view(). Elas são parecidas, mas bem diferentes no fundo.

scroll() roda a animação com base na posição de rolagem de um elemento.

Já view() é semelhante, porém acompanha o progresso do elemento conforme ele entra e sai do scrollport (ou seja, a área rolável do container em que ele está).

A escolha aqui é view(), porque existe uma área rolável clara dentro do container pai. A animação precisa rodar com base em onde o elemento entra e sai dessa área, e não na posição de rolagem dos itens das colunas.

Isso é bem interessante, porque dá para informar ao view() exatamente onde a animação deve começar quando o elemento entra na área rolável e onde deve parar quando ele sai. Assim:

/* Sintaxe oficial */
animation-timeline: view([ <axis> || <'view-timeline-inset'>]?);

O primeiro passo é definir os eixos:

@media screen and (width >= 50rem) {
/* mesmos estilos de antes */
.opposing-column {
/* ... */
animation-timeline: view();
animation-range: entry cover;
}
}

Isso ainda é só parte do que se quer. O que está declarado é que a animação deve (1) começar no exato momento em que o elemento entra no scrollport (entry) e (2) terminar quando ele sai completamente da área (cover).

Os insets precisam ser explícitos, porque são eles que estabelecem o intervalo da animação em relação à entrada e à saída. Como o intervalo deve ser completo, o entry começa em 0% e a saída acontece quando o item está cover em 100%.

@media screen and (width >= 50rem) {
/* mesmos estilos de antes */
.opposing-column {
/* ... */
animation-timeline: view();
animation-range: entry 0% cover 100%;
}
}

Por fim, a animação roda de forma linear, já que não há motivo para os itens acelerarem ou desacelerarem durante a rolagem.

@media screen and (width >= 50rem) {
/* mesmos estilos de antes */
.opposing-column {
/* ... */
animation-timing-function: linear;
animation-timeline: view();
animation-range: entry 0% cover 100%;
}
}

Ótimo. Só que ainda falta criar a animação em si. Está definido o que ela deve fazer quando rodar, mas não o movimento propriamente dito.

A ideia é montar três animações CSS separadas:

  • Uma que translada (move) os itens da primeira coluna para cima.
  • Uma que é o inverso da primeira, para os itens da outra coluna.

Tecnicamente, seria possível aplicar a primeira animação nas duas colunas externas, mas uma terceira animação, levemente deslocada em relação à primeira, deixa essas colunas com aparência escalonada.

@keyframes scroll1 {
from { transform: translateY(var(--opposing-mask)); }
to { transform: translateY(calc(var(--opposing-mask) * -1)); }
}
@keyframes scroll2 {
from { transform: translateY(calc(var(--opposing-mask) * -1)); }
to { transform: translateY(var(--opposing-mask)); }
}
@keyframes scroll3 {
from { transform: translateY(calc(var(--opposing-mask) * .66)); }
to { transform: translateY(calc(var(--opposing-mask) * -.33)); }
}

Dá para criar variáveis para elas, claro, caso seja preciso atualizá-las depois:

@media screen and (width >= 50rem) {
:root {
--opposing-bg: lightcyan;
--opposing-mask: 3rem;
--animation-1: scroll1;
--animation-2: scroll2;
--animation-3: scroll3;
/* ... */
}
}

…e aplicá-las a cada coluna:

@media screen and (width >= 50rem) {
/* mesmos estilos de antes */
.opposing-column {
/* mesmos estilos de antes */
}
:where(.opposing-column:nth-of-type(1)) {
animation-name: var(--animation-1);
}
:where(.opposing-column:nth-of-type(2)) {
animation-name: var(--animation-2);
}
:where(.opposing-column:nth-of-type(3)) {
animation-name: var(--animation-3);
}
}

Já que se está mexendo nisso, vale desabilitar as animações para respeitar as configurações de redução de movimento (e remover a máscara, que sem o movimento pode ficar estranha):

@media (prefers-reduced-motion: reduce) {
.opposing-column {
animation: unset;
&:before,
&:after {
content: unset;
}
}
}

Conclusão

As scroll-driven animations são realmente muito interessantes. O suporte no Firefox ainda estava pendente quando o artigo original foi escrito, mas dá para contornar isso com @supports.

Assim, é possível oferecer a experiência padrão com as anotações de scroll e um fallback para navegadores sem suporte, como rodar em uma timeline de animação normal:

@supports (animation-timeline: view()) {
/* ... */
}

E isso é só a ponta do iceberg do que as scroll-driven animations podem fazer. Para outros efeitos ligados à rolagem sem uma linha de JavaScript, vale ver também navegação âncora e smooth scroll com CSS puro e as receitas de View Transitions.

Que tipo de coisa você já criou ou testou com scroll-driven animations? Ou abordaria esse caso de outro jeito?