Mouse Follower 60fps

Eu sempre achei muito massa quando entrava em algum site criativo e via aquela bolinha seguindo o mouse, com um movimento suave e fluido. Parece simples, mas por trás disso tem uma implementação técnica bem interessante. Neste artigo, vamos analisar um código onde implementei um Mouse Follower no meu site, React/Next.js e tailwind.css, e entender como ele funciona passo a passo.


O que é um Mouse Follower?

Um Mouse Follower é um elemento HTML que acompanha o cursor do usuário, mas com um pequeno atraso proposital. Esse atraso cria a sensação de inércia. O resultado é uma animação fluida que enriquece a experiência visual sem prejudicar a usabilidade.


A estrutura do elemento no JSX

<div
  ref={followerRef}
  id="follower"
  className="mix-blend-difference hidden md:block fixed w-4 h-4 bg-orange-500 dark:bg-orange-400 rounded-full pointer-events-none -translate-x-1/2 -translate-y-1/2 z-[1000]"
/>

Cada classe tem um papel específico:

ClasseFunção
fixedMantém o elemento relativo à viewport, não ao scroll
w-4 h-4Tamanho de 16×16px
rounded-fullTransforma o div em um círculo perfeito
pointer-events-noneO follower não intercepta cliques nem eventos de mouse
-translate-x-1/2 -translate-y-1/2Centraliza o círculo no ponto exato do cursor
mix-blend-differenceModo de mistura que inverte a cor do fundo sob o elemento
hidden md:blockDesativa o follower em telas menores que md (768px) — em mobile o cursor não existe
z-[1000]Garante que o follower fique acima de todo o conteúdo

Sem o pointer-events-none, o próprio follower bloquearia os eventos de mouse, impedindo cliques nos elementos abaixo.


O useRef como ponte para o DOM

const followerRef = useRef<HTMLDivElement | null>(null)

useRef cria uma referência mutável que aponta diretamente para o elemento DOM, que possibilita o manipular no React sem causar re-renders. Ao anexar ref={followerRef} ao <div>, temos acesso direto ao nó HTML dentro de useEffect.


O coração da animação: o useEffect

Todo o comportamento do follower vive dentro de um único useEffect com array de dependências vazio ([]), o que significa que ele roda uma única vez, logo após a montagem do componente.

useEffect(() => {
  const follower = followerRef.current
  if (!follower) return

  let mouseX = window.innerWidth / 2
  let mouseY = window.innerHeight / 2
  let currentX = mouseX
  let currentY = mouseY

  const easeAmount = 0.1
  let rafId: number

  // ...

  return () => {
    document.removeEventListener('mousemove', handleMouseMove)
    cancelAnimationFrame(rafId)
  }
}, [])

Inicialização das variáveis

As variáveis de posição são iniciadas no centro da tela (window.innerWidth / 2, window.innerHeight / 2). Isso evita que o círculo apareça no canto (0, 0) antes do primeiro movimento do mouse.

São criadas duas posições distintas:

  • mouseX / mouseY — a posição real e instantânea do cursor
  • currentX / currentY — a posição atual do follower, que se aproxima do cursor suavemente

Capturando o mouse com mousemove

const handleMouseMove = (e: MouseEvent) => {
  mouseX = e.clientX
  mouseY = e.clientY
}

document.addEventListener('mousemove', handleMouseMove, {
  passive: true,
})

O listener é adicionado no document (não em um elemento específico) para capturar o cursor em qualquer parte da página.

A opção { passive: true } é uma otimização de performance: ela informa ao navegador que o handler nunca chamará preventDefault(), liberando o thread principal para processar o scroll sem esperar pelo handler.

Perceba que mouseX, mouseY, currentX e currentY são variáveis let comuns, não useState. Isso é intencional. Atualizar estado React a cada movimento do mouse causaria muitos re-renders por segundo, destruindo a performance.


Animando com efeito easing linear

const animate = () => {
  currentX += (mouseX - currentX) * easeAmount
  currentY += (mouseY - currentY) * easeAmount

  follower.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`

  rafId = requestAnimationFrame(animate)
}

Esta é a parte mais bonita do código. A fórmula:

currentX += (mouseX - currentX) * 0.1

É um interpolador linear (lerp). A cada frame, o follower percorre 10% da distância restante até o cursor. Isso cria um movimento que:

  • É rápido no início (quando a distância é grande)
  • Desacelera suavemente ao se aproximar do destino
  • Nunca para completamente (matematicamente, sempre há uma fração de distância restante), o que dá a sensação de leveza contínua

Com easeAmount = 0.1, o efeito é bem perceptível. Valores menores (ex: 0.05) criam mais inércia; valores maiores (ex: 0.3) tornam o follower mais rápido.

Por que translate3d em vez de left/top?

follower.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`

Manipular left e top força o navegador a recalcular o layout (reflow), o que é custoso. transform: translate3d(...) opera exclusivamente na camada de composição da GPU, sem afetar o layout, apróximando a animação dos tão sonhados 60 quadros por segundo.

O 0 no eixo Z (translate3d(x, y, 0)) é proposital: força o navegador a criar uma camada de composição dedicada para o elemento, otimizando ainda mais o rendering.


requestAnimationFrame: animação sincronizada com o display

const animate = () => {
  // ...atualiza posição...
  rafId = requestAnimationFrame(animate)
}

rafId = requestAnimationFrame(animate)

requestAnimationFrame (rAF) agenda a execução da função animate para o próximo frame de pintura do navegador — geralmente 60 vezes por segundo em displays comuns, ou 120fps em monitores de alta taxa.

Comparado a setInterval, o rAF tem vantagens importantes:

  • Pausa automaticamente quando a aba está em background, economizando recursos
  • Sincroniza com o ciclo de renderização do navegador, evitando tearing visual
  • Não acumula execuções atrasadas como setInterval pode fazer

O ID retornado (rafId) é guardado para poder cancelar o loop no cleanup.


Limpeza de recursos no cleanup

return () => {
  document.removeEventListener('mousemove', handleMouseMove)
  cancelAnimationFrame(rafId)
}

A função de retorno do useEffect é o cleanup — executado quando o componente é desmontado. Aqui, fazemos duas coisas cruciais:

  1. Remover o event listener para evitar memory leaks
  2. Cancelar o rAF para parar o loop de animação

Sem isso, o listener e o loop continuaria rodando mesmo após a desmontagem do componente.


Boas práticas implementadas

Separação de responsabilidades entre leitura e renderização. O mousemove apenas lê e armazena a posição. O requestAnimationFrame é o único responsável por atualizar o DOM. Isso é o padrão correto: nunca manipular o DOM diretamente dentro de event listeners de alta frequência.

Sem estado React para dados de animação. Toda a lógica usa variáveis JavaScript puras. useState seria um anti-pattern aqui — causaria re-renders desnecessários e poderia quebraria a animação.

Responsividade nativa. A classe hidden md:block desativa o follower em mobile sem nenhum JavaScript adicional, usando apenas Tailwind CSS.

mix-blend-mode: difference. Um detalhe de UX sofisticado: o follower automaticamente contrasta com qualquer fundo, seja claro ou escuro, sem precisar de lógica de tema.


Variações e customizações

Você pode ajustar o comportamento facilmente alterando alguns valores:

// Follower mais "pesado" (maior inércia)
const easeAmount = 0.05

// Follower instantâneo (sem easing)
const easeAmount = 1

// Tamanho maior ao clicar (exemplo de extensão)
document.addEventListener('mousedown', () => {
  follower.style.transform += ' scale(1.5)'
})

Para um efeito ainda mais sofisticado, é comum ter dois elementos — um menor que segue o cursor com easing baixo e um maior (o "trail") com easing ainda menor, criando um efeito de cauda.


Conclusão

O Mouse Follower deste código é um exemplo de animação de alta performance no browser. Ele combina três conceitos fundamentais de desenvolvimento front-end:

  • Lerp (Linear Interpolation) para criar suavidade sem bibliotecas externas
  • requestAnimationFrame para animações sincronizadas com o display
  • transform: translate3d para mover elementos na GPU sem disparar reflow

O resultado é um efeito visual polido com um custo de performance mínimo, exatamente o equilíbrio que buscamos alcançar em nossas animações web.