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.
À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; }}
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; } }}
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?