Introdução
Framer Motion é a biblioteca de animações mais usada no ecossistema React. A versão 11, lançada em 2024, trouxe mudanças importantes: suporte a animações híbridas (CSS nativo + JavaScript), a nova API scroll() para animações baseadas em rolagem sem precisar de JavaScript pesado, e o hook useAnimate com escopo de seletor. Neste post vou cobrir os conceitos que uso no dia a dia em projetos Next.js, do nível médio ao avançado.
Se você já sabe o que é motion.div e conhece initial, animate e exit, este post começa exatamente de onde você parou.
Por que Framer Motion ainda é a escolha certa em 2025
Antes de mergulhar no código, contexto rápido: o ecossistema de animações em React evoluiu. Hoje existem alternativas como AutoAnimate (animações declarativas automáticas), React Spring (física pura) e as animações nativas do CSS com @starting-style. A Framer Motion ainda lidera porque:
- A API de layout animations é única: sem ela, animar mudanças de posição/tamanho no DOM exige cálculos manuais de
getBoundingClientRect. - O suporte a gestures (drag, hover, tap, pan, focus) está tudo em um lugar, sem precisar de bibliotecas extras.
- A integração com React é profunda, inclusive com o App Router do Next.js 15.
- Performance: desde a v10, animações de
transformeopacitysão delegadas ao CSS sempre que possível, evitando reflows.
Instalação e setup TypeScript
A instalação é direta. Os tipos já vêm incluídos no pacote principal a partir da v6:
npm install framer-motion
Em um projeto Next.js com App Router, componentes animados precisam ser Client Components. A prática que adotei é criar um wrapper para cada animação reutilizável:
'use client';
import { motion, HTMLMotionProps } from 'framer-motion';
type FadeInProps = HTMLMotionProps<'div'> & {
delay?: number;
};
export function FadeIn({ children, delay = 0, ...props }: FadeInProps) {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay, ease: 'easeOut' }}
{...props}
>
{children}
</motion.div>
);
}
Isso evita 'use client' espalhado por todo o projeto e centraliza as configurações de animação.
Variants: o conceito que mais muda seu código
Variants são objetos nomeados que descrevem estados de animação. O detalhe que pouca gente usa bem é a propagação automática: quando um componente pai entra no estado show, todos os filhos com variants recebem o mesmo sinal sem precisar de props.
'use client';
import { motion, Variants } from 'framer-motion';
const containerVariants: Variants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 24 },
show: {
opacity: 1,
y: 0,
transition: {
type: 'spring',
stiffness: 180,
damping: 22,
},
},
};
type Item = { id: string; label: string };
export function StaggeredList({ items }: { items: Item[] }) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="show"
style={{ listStyle: 'none', padding: 0 }}
>
{items.map((item) => (
<motion.li key={item.id} variants={itemVariants}>
{item.label}
</motion.li>
))}
</motion.ul>
);
}
O staggerChildren: 0.1 faz cada filho animar com 100ms de atraso entre si. O pai propaga o estado show automaticamente para os filhos: nenhum filho precisa declarar initial ou animate individualmente.
AnimatePresence: animações de saída de verdade
O React desmonta componentes imediatamente. O AnimatePresence segura o componente no DOM até a animação de saída completar, usando a prop exit.
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
type Notification = { id: number; message: string };
export function NotificationStack() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [count, setCount] = useState(0);
const add = () => {
const id = count + 1;
setCount(id);
setNotifications((prev) => [
{ id, message: `Notificação #${id}` },
...prev,
]);
};
const remove = (id: number) =>
setNotifications((prev) => prev.filter((n) => n.id !== id));
return (
<div>
<button onClick={add}>Adicionar</button>
<AnimatePresence initial={false}>
{notifications.map((notif) => (
<motion.div
key={notif.id}
layout
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
animate={{ opacity: 1, height: 'auto', marginBottom: 8 }}
exit={{ opacity: 0, x: 60, height: 0, marginBottom: 0 }}
transition={{ type: 'spring', stiffness: 250, damping: 28 }}
>
<span>{notif.message}</span>
<button onClick={() => remove(notif.id)}>×</button>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
Dois detalhes importantes aqui. O initial={false} no AnimatePresence desativa a animação de entrada para itens que já estão presentes quando o componente monta: útil em listas carregadas do servidor. E a prop layout nos filhos garante que os itens restantes deslizem suavemente quando um item é removido.
Gestures: drag, hover e tap
Framer Motion unifica todos os eventos de interação em uma API declarativa. Você não precisa de onMouseEnter, onMouseLeave nem gerenciar estado para animações simples de hover:
'use client';
import { motion } from 'framer-motion';
export function InteractiveCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
whileHover={{ scale: 1.03, y: -4 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
style={{ cursor: 'pointer', borderRadius: 12, padding: 24 }}
>
{children}
</motion.div>
);
}
export function DraggableChip({ label }: { label: string }) {
return (
<motion.div
drag
dragConstraints={{ left: -80, right: 80, top: -40, bottom: 40 }}
dragElastic={0.15}
dragMomentum={false}
whileDrag={{ scale: 1.08, rotate: 3, zIndex: 10 }}
whileHover={{ scale: 1.04 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{ display: 'inline-block', cursor: 'grab', padding: '8px 16px', borderRadius: 20 }}
>
{label}
</motion.div>
);
}
O dragElastic controla o quanto o elemento pode ultrapassar as dragConstraints antes de ser puxado de volta. Valores entre 0.1 e 0.2 criam um efeito físico realista. Já dragMomentum: false evita que o elemento continue deslizando após soltar.
useMotionValue e useTransform: animações baseadas em estado físico
Aqui é onde a Framer Motion separa os avançados. useMotionValue cria um valor reativo que não causa re-renders do React quando atualiza. Isso é crucial para animações de alta frequência (60fps+). O useTransform mapeia um MotionValue em outro, como um map funcional para valores contínuos:
'use client';
import { useRef } from 'react';
import { motion, useMotionValue, useTransform, useSpring } from 'framer-motion';
export function TiltCard({ children }: { children: React.ReactNode }) {
const cardRef = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
// mapeia posição do mouse para rotação
const rotateX = useTransform(y, [-100, 100], [15, -15]);
const rotateY = useTransform(x, [-100, 100], [-15, 15]);
// adiciona física de mola para suavizar
const springRotateX = useSpring(rotateX, { stiffness: 200, damping: 30 });
const springRotateY = useSpring(rotateY, { stiffness: 200, damping: 30 });
// brilho que acompanha o cursor
const glowX = useTransform(x, [-100, 100], ['0%', '100%']);
const glowY = useTransform(y, [-100, 100], ['0%', '100%']);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = cardRef.current?.getBoundingClientRect();
if (!rect) return;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
x.set(e.clientX - centerX);
y.set(e.clientY - centerY);
};
const handleMouseLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={cardRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{
rotateX: springRotateX,
rotateY: springRotateY,
transformStyle: 'preserve-3d',
perspective: 800,
}}
>
{children}
</motion.div>
);
}
O ponto crítico: note que não há um único useState aqui. Toda a lógica de animação passa pelos MotionValues, que são atualizados fora do ciclo de renderização do React. Isso é o que garante 60fps consistente mesmo em dispositivos menos potentes.
useScroll: animações vinculadas à rolagem
No Framer Motion 11, o hook useScroll retorna scrollYProgress (um MotionValue de 0 a 1), que você pode conectar diretamente a qualquer propriedade visual:
'use client';
import { useRef } from 'react';
import { motion, useScroll, useTransform, useSpring } from 'framer-motion';
export function ParallaxSection() {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'],
});
// mola suaviza o scrollYProgress bruto
const smoothProgress = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
const y = useTransform(smoothProgress, [0, 1], ['-20%', '20%']);
const opacity = useTransform(smoothProgress, [0, 0.3, 0.7, 1], [0, 1, 1, 0]);
const scale = useTransform(smoothProgress, [0, 0.5, 1], [0.9, 1, 0.9]);
return (
<div ref={ref} style={{ overflow: 'hidden', borderRadius: 16 }}>
<motion.div style={{ y, opacity, scale }}>
Conteúdo com parallax suave
</motion.div>
</div>
);
}
O array offset no useScroll define quando o progresso começa e termina em relação ao viewport. 'start end' significa “quando o topo do elemento toca o rodapé do viewport”. 'end start' significa “quando o rodapé do elemento toca o topo do viewport”.
Layout animations e layoutId: magia sem matemática
Layout animations detectam automaticamente quando um elemento muda de posição ou tamanho no DOM e animam essa transição. O layoutId vai além: conecta dois elementos distintos em componentes diferentes, criando uma transição fluida entre eles.
O caso mais clássico é um modal que “expande” a partir de um card:
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
type Product = { id: string; name: string; description: string };
export function ProductGrid({ products }: { products: Product[] }) {
const [selected, setSelected] = useState<string | null>(null);
const selectedProduct = products.find((p) => p.id === selected);
return (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
{products.map((product) => (
<motion.div
key={product.id}
layoutId={`card-${product.id}`}
onClick={() => setSelected(product.id)}
style={{ borderRadius: 12, padding: 16, cursor: 'pointer' }}
whileHover={{ scale: 1.02 }}
>
<motion.h3 layoutId={`title-${product.id}`}>{product.name}</motion.h3>
</motion.div>
))}
</div>
<AnimatePresence>
{selectedProduct && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
exit={{ opacity: 0 }}
onClick={() => setSelected(null)}
style={{ position: 'fixed', inset: 0, background: '#000', zIndex: 10 }}
/>
<motion.div
layoutId={`card-${selectedProduct.id}`}
style={{
position: 'fixed',
top: '10%', left: '10%', right: '10%',
borderRadius: 20, padding: 32,
zIndex: 20,
}}
>
<motion.h2 layoutId={`title-${selectedProduct.id}`}>
{selectedProduct.name}
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
>
{selectedProduct.description}
</motion.p>
<button onClick={() => setSelected(null)}>Fechar</button>
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}
O mesmo layoutId no card da grid e no modal cria a ilusão de que o card “vira” o modal. O texto com layoutId também se move suavemente para sua nova posição. Isso que antes exigia FLIP animations manuais (First, Last, Invert, Play) está agora em três linhas.
useAnimate: controle imperativo com escopo
Introduzido no Framer Motion 10 e refinado na v11, o useAnimate substitui o antigo useAnimation. A diferença principal: ele recebe um escopo (ref do container) e permite selecionar filhos por CSS selector, sem precisar de refs individuais:
'use client';
import { useAnimate, stagger } from 'framer-motion';
export function MenuButton() {
const [scope, animate] = useAnimate();
const [isOpen, setIsOpen] = useState(false);
const handleClick = async () => {
const next = !isOpen;
setIsOpen(next);
if (next) {
// abre: rotaciona o botão e aparece os itens em stagger
await animate(scope.current, { rotate: 45 });
animate('li', { opacity: 1, y: 0 }, { delay: stagger(0.05) });
} else {
// fecha: some os itens e volta o botão
await animate('li', { opacity: 0, y: -8 }, { delay: stagger(0.03) });
animate(scope.current, { rotate: 0 });
}
};
return (
<div ref={scope}>
<button onClick={handleClick} style={{ fontSize: 24 }}>+</button>
<ul>
{['Editar', 'Compartilhar', 'Excluir'].map((label) => (
<li key={label} style={{ opacity: 0, transform: 'translateY(-8px)' }}>
{label}
</li>
))}
</ul>
</div>
);
}
O stagger importado diretamente funciona como um helper para o delay: distribui o delay automaticamente entre todos os elementos selecionados pelo selector 'li'.
Transições de página no Next.js App Router
Com o App Router, não existe mais o _app.tsx como ponto único de controle. A abordagem que funciona em produção é criar um PageTransition client component e usá-lo no layout raiz:
// components/page-transition.tsx
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
const variants = {
initial: { opacity: 0, y: 12 },
enter: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] } },
exit: { opacity: 0, y: -12, transition: { duration: 0.2 } },
};
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
variants={variants}
initial="initial"
animate="enter"
exit="exit"
>
{children}
</motion.div>
</AnimatePresence>
);
}
// app/layout.tsx
import { PageTransition } from '@/components/page-transition';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<body>
<PageTransition>{children}</PageTransition>
</body>
</html>
);
}
O mode="wait" garante que a página anterior sai completamente antes da nova entrar. O key={pathname} é o que faz o AnimatePresence reconhecer que é um componente diferente a cada troca de rota.
Performance: o que eu monitorei e o que aprendi
Algumas práticas que ficaram no meu workflow depois de depurar animações com o Chrome DevTools Performance:
- Só anime transform e opacity. Qualquer animação de
width,height,top,left,paddingoumargincausa layout recalculation a cada frame. O Chrome DevTools mostra isso como faixas roxas longas na timeline. Usescaleetranslateno lugar de dimensões. - will-change com moderação. O Framer Motion adiciona
will-change: transformautomaticamente durante animações ativas. Não adicione isso manualmente via CSS para elementos que ficam estáticos na maior parte do tempo: consome memória de GPU sem motivo. - Reduza motion para acessibilidade. A especificação WCAG 2.2 (Success Criterion 2.3.3) recomenda respeitar a preferência
prefers-reduced-motion. O Framer Motion tem suporte nativo via hook:
import { useReducedMotion } from 'framer-motion';
export function AnimatedComponent() {
const prefersReducedMotion = useReducedMotion();
return (
<motion.div
animate={{
x: prefersReducedMotion ? 0 : 100,
opacity: 1,
}}
transition={{
duration: prefersReducedMotion ? 0 : 0.4,
}}
/>
);
}
- LazyMotion para bundles menores. O pacote completo do Framer Motion tem cerca de 45KB gzipped. Com
LazyMotion, você carrega só as features que usa:
import { LazyMotion, domAnimation, m } from 'framer-motion';
// domAnimation inclui gestures, layout e AnimatePresence
// domMax adiciona drag avançado e layout cross-component
// m é o motion com tree-shaking automático
export function App() {
return (
<LazyMotion features={domAnimation}>
<m.div animate={{ opacity: 1 }} />
</LazyMotion>
);
}
Nos meus testes com Next.js Bundle Analyzer, essa troca reduziu o chunk do Framer Motion de ~45KB para ~15KB gzipped em projetos que não usam drag complexo.
Debugging: o que fazer quando a animação não funciona
Situações que me travaram e as soluções:
- Layout animation não anima: o elemento provavelmente está dentro de um container com
overflow: hidden. Adicionelayoutno container também, ou uselayoutRoot. - AnimatePresence não chama exit: o componente filho não está diretamente dentro do
AnimatePresence. Cada filho direto precisa ter umakeyúnica. - Animação trava no initial: verifique se o componente não está sendo remontado por uma
keyque muda desnecessariamente no componente pai. - useMotionValue não atualiza a UI: você precisa passar o MotionValue para a prop
style, não paraanimate. MotionValues são para estado contínuo, não para animações declarativas.
Referências e fontes
- Documentação oficial: framer.com/motion
- WCAG 2.2, Success Criterion 2.3.3 (Animation from Interactions): w3.org/TR/WCAG22
- Chrome Developers, Rendering Performance: developer.chrome.com/docs/devtools/performance
- Matt Perry (autor do Framer Motion), “Animating the Impossible”: YouTube
“A melhor animação é aquela que o usuário não percebe conscientemente, mas sente falta quando some.” O trabalho de um bom frontend não é impressionar com movimento. É usar o movimento para tornar a interface mais clara, mais confiável e mais agradável de usar. O Framer Motion só é uma boa escolha quando você entende quando não usar ele.
Conclusão
Cobrimos bastante coisa aqui: variants com propagação automática, AnimatePresence para saídas com física, gestures declarativos, MotionValues para animações de alta frequência sem re-renders, scroll animations com useScroll, layout animations e o layoutId para transições entre componentes, o useAnimate para controle imperativo com seletores CSS, transições de página no App Router e práticas de performance que medi em projetos reais.
O que recomendo como próximo passo: pegue um componente que você já tem em produção, identifique uma interação que hoje não dá nenhum feedback visual, e adicione exatamente uma animação com o menor código possível. É assim que animações se tornam parte natural do seu processo, não um feature especial que você adiciona no final.
Se tiver dúvidas sobre alguma implementação específica, deixa nos comentários. Leio todos.
Gostou do conteúdo?
Se você precisar de ajuda aplicando essas técnicas no seu projeto, estou disponível para consultoria.
Autor
Paulo Reducino
Desenvolvedor Frontend com 5+ anos de experiência em React, Next.js e TypeScript. Especialista em performance, SEO e acessibilidade. Atualmente na Vizuh (Londres, UK).



