Hover por proximidade com CSS moderno
Estilizar :hover é trivial. Afetar o elemento seguinte já dá trabalho, o anterior um pouco mais, e vários em qualquer direção dentro de um grid responsivo parece caso para JavaScript. Não é: este hover por proximidade sai com CSS puro.
O termo talvez não seja conhecido (ou talvez esteja sendo cunhado agora), então o melhor começo é uma demonstração para entender o que significa “hover por proximidade”.
Ao passar o mouse sobre um avatar, o efeito de escala atinge o elemento principal e também os adjacentes. Existe uma espécie de proximidade no efeito, em que elementos perto do cursor são afetados com fatores de escala diferentes.
Em uma configuração 1D (horizontal ou vertical), a estrutura do código é bem simples. É uma combinação de seletores diferentes para atingir o elemento sob o cursor e os adjacentes.
img:hover { scale: 1.5;}img:hover + *,img:has(+ *:hover) { scale: 1.2;}img:hover + * + *,img:has(+ * + *:hover) { scale: 1.1;}O código é praticamente autoexplicativo. O + seleciona os elementos “seguintes”, enquanto :has() combinado com + seleciona os “anteriores”. Dá para estender a quantos elementos se quiser, apenas adicionando mais seletores.
E em uma configuração 2D? Seria bom obter o mesmo efeito dentro de um grid, afetando no hover os elementos em volta do principal. É isso que este artigo faz.
Na data de publicação do original, apenas Chrome e Edge dão suporte completo aos recursos usados aqui.
Esta é a demonstração do que será construído. Passe o mouse por ela e veja o que acontece.
Além do efeito de hover por proximidade, a coisa toda é responsiva: dá para redimensionar a tela e o hover continua funcionando. Também será mostrado como criar mais variações com a mesma técnica.
A configuração do grid
A base é CSS Grid na configuração responsiva clássica:
.container { --s: 50px; --g: 10px;
display: grid; grid-template-columns: repeat(auto-fill,minmax(var(--s),1fr)); gap: var(--g);}Não há obrigação de usar essa configuração. O requisito principal é ter a mesma largura para todas as colunas e a mesma altura para as linhas (que pode ser diferente da largura).
Também funcionaria o seguinte, por exemplo:
.container { --s: 50px; --g: 10px;
display: grid; grid-template-columns: repeat(10,var(--s)); gap: var(--g);}Que é um grid estático de 10 colunas. Saber que o grid tem número fixo de colunas facilita o trabalho. O desafio maior está na configuração responsiva, com uma solução genérica que dá para adaptar ao caso específico (e provavelmente mais simples) de cada um.
A ideia
Selecionar o elemento anterior ou o seguinte é simples, mas como selecionar elementos em uma configuração 2D? O de cima, o de baixo, o da diagonal? Não vale procurar seletores específicos: eles não existem.
Mesmo em um grid, todos os elementos continuam adjacentes, então o elemento de cima é um irmão anterior, anterior, …, anterior, e o de baixo é um irmão seguinte, seguinte, …, seguinte. Ao acrescentar o índice de cada elemento, isso fica mais claro.
O exemplo acima é um grid de 5 colunas. Considerando o elemento amarelo como o que está sob o cursor, é preciso atingir os elementos 7, 8, 9, 12, 14, 17, 18 e 19.
Percebeu o truque? São 4 elementos no grupo dos “seguintes” e 4 no grupo dos “anteriores”. Tudo se resume a calcular quantos + usar dentro do seletor. Este é o elemento 7, e os demais ficam como exercício:
.item:has(+ * + * + * + * + * + *:hover) {
}Além de feio, isso funciona apenas quando o número de colunas é conhecido. Com a configuração responsiva não vai funcionar, porque não é possível criar seletores dinâmicos. Então não: a solução não vai depender de seletores.
A ideia dos seletores não funcionaria mesmo, mas precisava ser mencionada porque é a primeira que vem à cabeça. Afinal, é provavelmente o único jeito de afetar outros elementos a partir de um específico. Graças ao CSS moderno, existem mecanismos novos para simular a seleção desejada.
Como o trabalho é em um grid 2D, em vez de considerar os índices dá para usar coordenadas. Cada elemento está numa linha e numa coluna específicas, então tem uma coordenada [x, y]. Suponha agora que também existam as coordenadas do elemento sob o cursor (chamadas de [i, j]), disponíveis para todos os elementos.
.container > * {
--x: ; --y: ;
--i: ; --j: ;}Visto de outro jeito: as variáveis --x e --y têm valores diferentes para cada elemento e nunca mudam, enquanto --i e --j mudam e guardam as coordenadas do elemento sob o cursor.
A figura ajuda a entender:

Cada elemento tem as próprias coordenadas [x, y], mas todos compartilham as mesmas coordenadas [i, j] do elemento sob o cursor (o vermelho).
Com essa informação, alguns testes identificam os elementos a atingir. O elemento de cima, por exemplo, tem y = j e x = i - 1. O de baixo tem y = j e x = i + 1. E assim por diante.
O CSS consegue fazer isso? Consegue.
Pode parecer que a recuperação das coordenadas, os testes e a aplicação dos estilos vão depender de JavaScript, mas não. A implementação é só CSS, e é uma boa oportunidade para explorar recursos modernos.
A implementação
Para facilitar o acompanhamento, a implementação se divide em três partes.
As duas primeiras (em especial a segunda) são as mais complicadas, e também as que definem a lógica interna que não muda. A terceira é a lógica de seleção: a mais fácil e a mais importante, porque é ali que se mexe para criar variações do efeito.
Obtendo as coordenadas de todos os itens
Para calcular as coordenadas de cada elemento, é preciso descobrir o número de colunas. Já que estamos nisso, vale calcular também o número de linhas, que será necessário depois.
.container { --s: 50px; --g: 10px;
container-type: inline-size;}.container > * {
--n: round(down,(100cqw + var(--g))/(var(--s) + var(--g)));
--m: round(up,sibling-count()/var(--n));}O contêiner é declarado como “container” para que 100cqw sirva de largura total disponível. Essa largura é dividida pelo tamanho de cada elemento, o que revela quantos elementos cabem ali. O resultado é arredondado para baixo, para virar inteiro.
A fórmula também considera o gap. Como o gap fica entre os elementos, existem N - 1 gaps, e é por isso que a conta não é 100cqw/(s + g), mas (100cqw + g)/(s + g). Ana Tudor explicou bem esse cálculo com figuras claras.
Para obter o número de linhas, o total de filhos (via sibling-count()) é dividido pelo número de colunas. Aqui o arredondamento é para cima, porque a fração significa que a última linha não está completamente preenchida.
Com 22 itens e 6 colunas, por exemplo, o resultado é 3,67, ou seja, são necessárias 4 linhas (3 preenchidas com 6 itens e a última com apenas 4).
Agora dá para calcular as coordenadas [x, y]. Mais precisamente, o índice de cada elemento (obtido com sibling-index()) será convertido em coordenadas.
São 5 colunas e, por exemplo, o elemento de índice 17 tem as coordenadas [3, 1]. Considerando o número de colunas como a variável N, a fórmula é:
(index - 1) = x * N + yTodos os valores são inteiros: x é o quociente e y é o resto. Sem transformar isso em aula de matemática, veja como fica em CSS:
.container { --s: 50px; --g: 10px;
container-type: inline-size;}.container > * {
--n: round(down,(100cqw + var(--g))/(var(--s) + var(--g)));
--m: round(up,sibling-count()/var(--n));
--x: round(down,(sibling-index() - 1)/var(--n)); --y: mod(sibling-index() - 1,var(--n));}O número de linhas ainda não foi usado. Ele é necessário para calcular as coordenadas do item sob o cursor.
Obtendo as coordenadas do item sob o cursor
Dissecando o código da demonstração do início, a parte que calcula as coordenadas dos itens é fácil de entender. A que calcula as coordenadas do item sob o cursor é mais complicada: tem animações, um view-timeline no hover e outras coisas.
A solução usa scroll-driven animations. Não há nada para animar nem para rolar, e ainda assim é esse o recurso necessário. Segundo a especificação, existem dois tipos de timeline dirigida por rolagem:
Scroll Progress Timelines, ligadas ao progresso de rolagem de um contêiner de rolagem específico, e View Progress Timelines, ligadas ao progresso de visualização de uma caixa específica através de um scrollport.
O segundo tipo é o desejado. A definição é formal e não muito palpável, mas significa que é possível rastrear a posição de um elemento dentro de um contêiner rolável, e que essa posição pode controlar o progresso de uma animação.
Um contêiner rolável, mas não havia nada para rolar? Para esta parte, basta acrescentar overflow: hidden ao contêiner. Nada precisa rolar, mas para um elemento ser um “contêiner de rolagem” ele precisa ter um valor de overflow diferente de visible ou clip.
Pense nisso como o position: relative que se adiciona ao contêiner quando se usa position: absolute em um filho.
.container { overflow: hidden;}De volta ao grid: cada elemento tem uma posição diferente dentro do contêiner, então, se todos forem usados para controlar a mesma animação, cada um vai devolver um progresso diferente. É essa diferença que dá as coordenadas do item sob o cursor.
Ainda confuso? Uma demonstração simples:
Há um contêiner com um elemento dentro. Quando o elemento está à esquerda, o fundo é vermelho; quando está à direita, é verde. A posição controla uma animação de background definida no contêiner.
.container { overflow: hidden;
timeline-scope: --_i; animation: --_i linear both; animation-timeline: --_i; animation-range: entry 100% exit 0%;}@keyframes --_i { 0% {background: green} 100% {background: red }}.container > * { view-timeline: --_i x;}O elemento filho define uma timeline customizada com view-timeline. Essa timeline é usada pelo pai com animation-timeline. Sempre que a posição do filho muda no eixo x, o progresso da animação muda também.
Dois pontos de atenção. O primeiro é o uso de --_i em todo lugar, o que confunde um pouco. Na prática existem dois --_i: o que define a timeline customizada e o que define o nome da animação. Animações aceitam a notação <custom-ident> e, não, o navegador não se confunde quando o mesmo nome aparece em lugares diferentes.
O segundo é o uso de animation-range. A função dele é calibrar o intervalo conforme a necessidade: a posição da esquerda precisa corresponder aos 100% dos keyframes, e a da direita aos 0%.
Para quem está começando com scroll-driven animations, é normal se perder aqui. Isso não prejudica o entendimento do que vem depois, mas vale conferir o guia da MDN, um bom ponto de partida sobre o recurso.
Voltando à demonstração anterior: em vez de um elemento, agora são dois (um de cada lado). Passe o mouse em cada um e veja o que acontece.
O elemento sob o cursor define a timeline customizada, e a cor sai diferente porque cada elemento está em uma posição diferente.
.container > *:hover { view-timeline: --_i x;}Agora com mais elementos, preenchendo o contêiner inteiro:
De novo, cada elemento está ligado a uma cor diferente, dentro do intervalo entre verde e vermelho.
Já deu para ver onde isso vai chegar? Se, em vez do background, a animação for de uma variável de 0 a 1, com duas animações (uma para cada eixo), cada elemento vai devolver dois valores diferentes quando estiver sob o cursor. São as coordenadas.
Este é o código final:
.container { overflow: hidden;
timeline-scope: --_i,--_j; animation: --_i linear both,--_j linear both; animation-timeline: --_i,--_j; animation-range: entry 100% exit 0%;}@keyframes --_i { 0% {--_i: 1} 100% {--_i: 0}}@keyframes --_j { 0% {--_j: 1} 100% {--_j: 0}}
.container > * {
--n: round(down,(100cqw + var(--g))/(var(--s) + var(--g)));
--m: round(up,sibling-count()/var(--n));
--i: round(var(--_i)*(var(--m) - 1)); --j: round(var(--_j)*(var(--n) - 1));}.container > *:hover { view-timeline: --_j x, --_i y;}São duas timelines customizadas (uma para cada eixo) e duas animações, cada uma animando uma variável diferente.
O mesmo nome aparece de novo para coisas distintas: a timeline customizada (definida com view-timeline e usada por animation-timeline), o nome da animação e a variável animada que carrega a coordenada.
Vale olhar o grid outra vez e testar alguns exemplos para entender o funcionamento:
O elemento 1 fica no topo e à esquerda, então a posição dele dispara os 100% de cada keyframe, o que resulta em 0. O elemento 25 faz o oposto: dispara os 0% e resulta em 1. E os demais?
A lógica é simples. Todos os elementos têm o mesmo tamanho (é aqui que essa restrição se torna obrigatória), então existe um passo fixo para cada animação, igual a 1/(N - 1) para as colunas e 1/(M - 1) para as linhas.
Neste caso são 5 colunas (5 elementos na horizontal), então o passo é 0.25. A animação devolve os valores 0, 0.25, 0.5, 0.75, 1. Multiplicando esses valores pelo inverso do passo (N - 1), sai a coordenada x. A mesma lógica nas linhas dá a coordenada y.
.container > * {
--n: round(down,(100cqw + var(--g))/(var(--s) + var(--g)));
--m: round(up,sibling-count()/var(--n));
--i: round(var(--_i)*(var(--m) - 1)); --j: round(var(--_j)*(var(--n) - 1));}O round() está ali para garantir que o resultado final seja sempre inteiro.
Nesta demonstração interativa, cada elemento mostra todos os valores calculados até aqui (número de colunas e linhas, as próprias coordenadas e as coordenadas do elemento sob o cursor). Passe o mouse por elementos diferentes e veja os valores mudarem. E, como tudo é responsivo, vale redimensionar a demonstração também.
As variáveis usadas na animação precisam ser registradas com @property. Como valor inicial, dá para usar qualquer valor fora do intervalo [0, 1], para garantir que os valores padrão (quando nenhum elemento está sob o cursor) não coincidam com nenhum elemento existente.
É por isso que o código usa -1, o que resulta em 1 - N e 1 - M como valores padrão de [i, j].
Esta parte pode ser difícil de assimilar, então vale reler. Papel e lápis também ajudam a dissecar as fórmulas. De todo modo, a próxima parte parte do princípio de que as coordenadas já existem, então quem não entendeu toda a lógica até aqui não fica travado na parte da seleção.
A seleção
Aqui entra o novo if(), porque é preciso expressar condições baseadas nas coordenadas. Começando pelo caso simples:
background: if( style((--i = --x) and (--j = --y)): red; else: green;);Fácil: se as duas coordenadas são iguais, o elemento selecionado é o que está sob o cursor. Para quem não conhece a sintaxe de if(), vale uma leitura rápida antes de continuar: The Hidden Trick of Style Queries and if().
Acrescentando outra condição:
background: if( style((--i = --x) and (--j = --y)): red; style((--i = calc(var(--x) - 1)) and (--j = --y)): yellow; else: green;);Um novo elemento selecionado, com uma nova cor. O truque é justamente esse: nada além de uma lista de condições em que cada condição seleciona um elemento e aplica um CSS específico a ele.
Seria preciso ter uma condição por elemento? Não, porque uma condição pode selecionar vários itens ao mesmo tempo. Veja este exemplo:
background: if( style((--x = --i) and (--y = --j)): red; style((abs(var(--x) - var(--i)) <= 1) and (abs(var(--y) - var(--j)) <= 1)): orange; style((abs(var(--x) - var(--i)) <= 2) and (abs(var(--y) - var(--j)) <= 2)): blue; else: green;);Se a diferença absoluta entre x e i for menor ou igual a 1 (o mesmo entre j e y), a seleção pega os elementos em volta do que está sob o cursor. Se for menor ou igual a 2, pega mais uma camada:
As possibilidades são infinitas, e a técnica combina com muitas propriedades. Na demonstração abaixo, o que muda é o background, o border-radius e o tamanho, via scale.
Até aqui a seleção foi precisa: elementos específicos recebendo estilos específicos. Dá para fazer diferente e, em vez de condições, aplicar estilos dinâmicos a todos os elementos.
Considere a variável a seguir:
--_d: hypot(var(--x) - var(--i),var(--y) - var(--j));Sem entrar nos detalhes matemáticos, a variável --_d contém um valor sem unidade com a distância entre um elemento e o que está sob o cursor. Um valor que serve em várias propriedades e rende outro efeito interessante:
border-radius: calc(50% - 4%*var(--_d));scale: max(.7,1.4 - .1*var(--_d));opacity: max(.4,1 - .1*var(--_d));Mais exemplos
O objetivo do artigo era criar o efeito de hover por proximidade, mas a técnica serve para outros tipos de efeito de hover.
Selecionar os elementos da mesma coluna, ou da mesma linha, ou das duas:
E o código é bem simples:
if( style((--x = --i) or (--y = --j)): style_of_selected; else: style_of_non_selected;);A diagonal também é possível:
if( style(abs(var(--x) - var(--i)) = abs(var(--y) - var(--j))): style_of_selected; else: style_of_non_selected;);E aqui vão mais alguns exemplos para dissecar. Como já dito, as possibilidades são infinitas.
Conclusão
O experimento é uma boa oportunidade para explorar capacidades novas do CSS. Dentro de uma configuração de grid responsivo, foi possível calcular o número de colunas e linhas, obter as coordenadas de cada elemento, obter as coordenadas do elemento sob o cursor e estilizar elementos específicos com base em condições.
Estamos longe daquela linguagem de estilo que servia apenas para definir cores, margens e larguras. Agora dá para fazer cálculos complexos, definir comportamentos condicionais, aplicar estilo dinâmico e muito mais. Isso já é o que se chama de “programar”?