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:
| Classe | Função |
|---|---|
fixed | Mantém o elemento relativo à viewport, não ao scroll |
w-4 h-4 | Tamanho de 16×16px |
rounded-full | Transforma o div em um círculo perfeito |
pointer-events-none | O follower não intercepta cliques nem eventos de mouse |
-translate-x-1/2 -translate-y-1/2 | Centraliza o círculo no ponto exato do cursor |
mix-blend-difference | Modo de mistura que inverte a cor do fundo sob o elemento |
hidden md:block | Desativa 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 cursorcurrentX / 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,currentXecurrentYsão variáveisletcomuns, nãouseState. 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
setIntervalpode 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:
- Remover o event listener para evitar memory leaks
- 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
requestAnimationFramepara animações sincronizadas com o displaytransform: translate3dpara 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.
