# Hover por proximidade com CSS moderno

> Como afetar os elementos ao redor daquele sob o cursor em um grid responsivo, calculando coordenadas com sibling-index(), scroll-driven animations e if().

URL: https://dpw.dev/css/hover-proximidade-css/  
Publicado em: 2026-09-14  
Categoria: css  
Tags: animacao-css, interface, layout  
Autor: Tárcio Zemel

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.

> Artigo baseado em [Hover Proximity Using Modern CSS](https://blog.master.dev/hover-proximity-using-modern-css/).

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".

[Exemplo no CodePen](https://codepen.io/anon/pen/XJpGZXX/8cf206eaae89182f6ade060aa6c646a1)

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.

```css
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.

[Exemplo no CodePen](https://codepen.io/anon/pen/EaZJrEy/0155af0100a991ba08b511dd76542a02)

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:

```css
.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:

```css
.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.

![Grid de cinco colunas com os índices numerados de cada elemento e o elemento central destacado em amarelo](/images/blog/hover-proximidade-css/grid-indices-5-colunas.webp)

*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:

```css
.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.

```css
.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:

<Image src="/images/blog/hover-proximidade-css/coordenadas-xy-ij.webp" alt="Grid em que cada elemento exibe suas coordenadas x e y, e todos compartilham as coordenadas i e j do elemento em vermelho">
  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.

```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));
}
```

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)`. <a href="https://master.dev/blog/count-auto-fill-columns/#mind-the-gap" target="_blank" rel="noopener noreferrer">Ana Tudor explicou bem esse cálculo com figuras claras</a>.

[Exemplo no CodePen](https://codepen.io/anon/pen/LEVqOpZ)

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.

![Figura relacionando os índices dos elementos de um grid de cinco colunas com as coordenadas correspondentes](/images/blog/hover-proximidade-css/coordenadas-e-indices.webp)

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 é:

```text
(index - 1) = x * N + y
```

Todos 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:

```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. <a href="https://www.w3.org/TR/scroll-animations-1/#intro" target="_blank" rel="noopener noreferrer">Segundo a especificação</a>, existem dois tipos de timeline dirigida por rolagem:

> <a href="https://www.w3.org/TR/scroll-animations-1/#scroll-timelines" target="_blank" rel="noopener noreferrer">Scroll Progress Timelines</a>, ligadas ao progresso de rolagem de um contêiner de rolagem específico, e <a href="https://www.w3.org/TR/scroll-animations-1/#view-timelines" target="_blank" rel="noopener noreferrer">View Progress Timelines</a>, 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.

```css
.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:

[Exemplo no CodePen](https://codepen.io/anon/pen/QwdoXpE/bbee27d71fcc64d1fe5506bad09c804b)

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.

```css
.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%`.

![Diagrama mostrando como animation-range calibra o intervalo entre as posições de entrada e saída do elemento](/images/blog/hover-proximidade-css/calibragem-animation-range.webp)

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 <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations" target="_blank" rel="noopener noreferrer">o guia da MDN</a>, 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.

[Exemplo no CodePen](https://codepen.io/anon/pen/yygwdzb/e5b77c47cb0eb7cde6c23d6454871b4d)

O elemento sob o cursor define a timeline customizada, e a cor sai diferente porque cada elemento está em uma posição diferente.

```css
.container > *:hover {
  view-timeline: --_i x;
}
```

Agora com mais elementos, preenchendo o contêiner inteiro:

[Exemplo no CodePen](https://codepen.io/anon/pen/ZYLNOYW/86e3045dbf890450cddd6cb0a692f577)

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:

```css
.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:

![Grid de cinco colunas com os valores calculados de coordenadas exibidos em cada elemento](/images/blog/hover-proximidade-css/valores-por-elemento.webp)

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`.

```css
.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.

[Exemplo no CodePen](https://codepen.io/anon/pen/XJpOwoa/ae8706773066d06c0dbb567db0ffa26b)

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:

```css
background: if(
  style((--i = --x) and (--j = --y)): red;
  else: green;
);
```

[Exemplo no CodePen](https://codepen.io/anon/pen/azprbLE/522e2d6df18362619dbb5f1c32e8e5ce)

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: <a href="https://css-tip.com/if-trick/" target="_blank" rel="noopener noreferrer">The Hidden Trick of Style Queries and if()</a>.

Acrescentando outra condição:

```css
background: if(
  style((--i = --x) and (--j = --y)): red;
  style((--i = calc(var(--x) - 1)) and (--j = --y)): yellow;
  else: green;
);
```

[Exemplo no CodePen](https://codepen.io/anon/pen/qERGBVY/b5e0b159c35dfa62582750f5de5d91ca)

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:

```css
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:

[Exemplo no CodePen](https://codepen.io/anon/pen/pvRmoaE/f902b8b85dbb17beb981e951f1d0c684)

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`.

[Exemplo no CodePen](https://codepen.io/anon/pen/ogBVjpO/90bf5aaa9712c7e64a6bcc4ae02db1a6)

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:

```css
--_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:

```css
border-radius: calc(50% - 4%*var(--_d));
scale: max(.7,1.4 - .1*var(--_d));
opacity: max(.4,1 - .1*var(--_d));
```

[Exemplo no CodePen](https://codepen.io/anon/pen/EaZJrEy/0155af0100a991ba08b511dd76542a02)

## 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:

[Exemplo no CodePen](https://codepen.io/anon/pen/LExvqzx/2302a9ede8a2706f0bdf2114d7d8beda)

E o código é bem simples:

```css
if(
  style((--x = --i) or (--y = --j)): style_of_selected;
  else: style_of_non_selected;
);
```

A diagonal também é possível:

[Exemplo no CodePen](https://codepen.io/anon/pen/PwWvoXo/e32d891e956803aee08f2f6570403927)

```css
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.

[Exemplo no CodePen](https://codepen.io/anon/pen/XJpwKXp/8976093aa62cdeced6b6e61a6a930b97)

[Exemplo no CodePen](https://codepen.io/anon/pen/JoEqJmv/31a2de69d27b3bd8968d9ddea253ea7e)

[Exemplo no CodePen](https://codepen.io/anon/pen/MYJdQpR/0746433c5925ff10a8a9cfa25a472c49)

## 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"?

---

Fonte: [dpw - desenvolvimento para web](https://dpw.dev/) (pt-BR).
Guia para agentes: https://dpw.dev/llms.txt
Índice completo: https://dpw.dev/sitemap-index.xml
Feed: https://dpw.dev/rss.xml
