Microinterações e Animações Fluidas com Framer Motion
Do básico ao avançado: variants, stagger, AnimatePresence, useScroll, layout animations e como garantir 60fps sem travar a thread principal.
Em produtos digitais de alta qualidade, a transição entre telas, o comportamento de um botão ou o surgimento de um card não são abruptos. Esses micro-momentos constroem percepção de qualidade no subconsciente do usuário. O Framer Motion é uma biblioteca declarativa para React que abstrai o complexo modelo de animação do browser e entrega uma API elegante, com animações baseadas em física e integração nativa com o ciclo de vida dos componentes.
Mas a maioria dos tutoriais fica apenas no initial/animate/transition. Neste artigo, vamos cobrir o que realmente diferencia uma interface mediana de uma premium: Variants, stagger de filhos, AnimatePresence para unmount, scroll-driven animations, layout animations e como medir e garantir performance real.
npm install framer-motionFundamentos: O modelo mental do Framer Motion
Todo motion.element opera com três estados principais:
- `initial` — estado antes da animação de entrada (posição, opacidade, escala inicial)
- `animate` — estado alvo para onde o elemento anima ao montar
- `exit` — estado para onde anima antes de ser removido do DOM (só funciona dentro de
AnimatePresence)
Por padrão, o Framer Motion usa spring physics (molas) ao invés de easing cubic-bezier. Isso resulta em animações que parecem físicas e naturais. Você pode ajustar com transition:
import { motion } from 'framer-motion';
export function SpringDemo() {
return (
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
// Spring: stiffness (rigidez), damping (amortecimento)
// Quanto maior stiffness, mais rápido. Menor damping = mais oscilação.
transition={{
type: 'spring',
stiffness: 260,
damping: 20,
}}
>
Conteúdo com física de mola
</motion.div>
);
}
// Para animações de opacidade simples, use 'tween' com easing:
// transition={{ type: 'tween', duration: 0.3, ease: 'easeOut' }}
// Spring é ruim para opacidade — use tween nesses casos.Variants: Reutilização e Consistência
O maior erro de quem começa com Framer Motion é espalhar objetos de animação inline em cada componente. Variants são a forma certa: você define os estados como um objeto reutilizável e referencia por nome. Isso centraliza a animação e habilita funcionalidades poderosas como propagação automática para filhos.
import { motion } from 'framer-motion';
// Definição das variantes fora do componente — sem re-criação a cada render
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
// staggerChildren: delay entre a animação de cada filho
staggerChildren: 0.1,
// delayChildren: delay antes do primeiro filho começar
delayChildren: 0.2,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { type: 'spring', stiffness: 300, damping: 24 },
},
};
interface CardListProps {
items: Array<string>;
}
export function CardList({ items }: CardListProps) {
return (
// Pai anima de 'hidden' para 'visible'
// Filhos com motion + variants herdam automaticamente esses nomes
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
>
{items.map((item, index) => (
<motion.li
key={index}
variants={itemVariants}
// Não precisa de initial/animate — herda do pai!
>
{item}
</motion.li>
))}
</motion.ul>
);
}
// Resultado: os cards entram com 100ms de delay entre cada um,
// criando aquele efeito cascata de listas que parece premium.Definir as variantes fora do componente é crucial para performance. Se você as define dentro, um novo objeto é criado a cada render, fazendo o Framer Motion reprocessar as animações desnecessariamente.
AnimatePresence: Animando o Unmount
O React remove componentes do DOM imediatamente quando a condição é false. O AnimatePresence intercepta esse unmount e deixa o componente executar sua animação exit antes de ser removido de verdade. É essencial para modais, toasts, drawers e qualquer conteúdo condicional.
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
const notificationVariants = {
initial: { opacity: 0, x: 60, scale: 0.95 },
animate: { opacity: 1, x: 0, scale: 1 },
exit: { opacity: 0, x: 60, scale: 0.95, transition: { duration: 0.2 } },
};
export function NotificationStack() {
const [notifications, setNotifications] = useState<Array<Notification>>([]);
function dismiss(id: string) {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}
return (
<div className="notification-stack">
{/* mode="popLayout" é mais eficiente para listas que mudam de tamanho */}
<AnimatePresence mode="popLayout">
{notifications.map((notification) => (
<motion.div
key={notification.id}
layout // Anima o reposicionamento dos outros itens quando um some
variants={notificationVariants}
initial="initial"
animate="animate"
exit="exit"
className={`notification notification--${notification.type}`}
>
<p>{notification.message}</p>
<button
type="button"
onClick={() => dismiss(notification.id)}
aria-label="Dispensar notificação"
>
✕
</button>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
// mode="wait" — espera o exit completar antes de animar o próximo (bom para page transitions)
// mode="sync" — entra e sai ao mesmo tempo (padrão)
// mode="popLayout" — ideal para listas (mais eficiente, evita layout shifts)Layout Animations: Transições de Layout Automáticas
Layout Animation é uma das features mais impressionantes do Framer Motion. Basta adicionar a prop layout a um motion.element e ele automaticamente anima qualquer mudança de posição ou tamanho que ocorra por causa de mudanças de estado, sem você precisar calcular coordenadas.
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
interface Task {
id: string;
text: string;
done: boolean;
}
export function TaskList({ tasks }: { tasks: Array<Task> }) {
const [items, setItems] = useState(tasks);
function toggleDone(id: string) {
setItems((prev) =>
prev.map((task) =>
task.id === id ? { ...task, done: !task.done } : task
).sort((a, b) => Number(a.done) - Number(b.done)) // Concluídos vão para o final
);
}
return (
<ul>
<AnimatePresence>
{items.map((task) => (
// layout="position" — anima apenas o movimento (não o tamanho)
// Quando a lista reordena, cada item desliza suavemente para sua nova posição
<motion.li
key={task.id}
layout="position"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<input
type="checkbox"
checked={task.done}
onChange={() => toggleDone(task.id)}
/>
<span style={{ textDecoration: task.done ? 'line-through' : 'none' }}>
{task.text}
</span>
</motion.li>
))}
</AnimatePresence>
</ul>
);
}
// Sem Framer Motion, você precisaria de:
// - Medir a posição de cada item antes e depois (getBoundingClientRect)
// - Calcular o delta de posição
// - Aplicar um transform inicial e animar para 0
// (padrão FLIP — First, Last, Invert, Play)
// O Framer Motion faz tudo isso automaticamente com a prop 'layout'.Scroll-Driven Animations com useScroll e useTransform
Animações controladas pelo scroll são extremamente populares em landing pages e portfólios. O Framer Motion oferece useScroll para rastrear a posição de scroll e useTransform para mapear esse valor para qualquer propriedade CSS — criando efeitos parallax, fade-on-scroll e progress bars sem uma linha de imperative code.
import { useRef } from 'react';
import { motion, useScroll, useTransform, useSpring } from 'framer-motion';
// --- Exemplo 1: Progress Bar de Leitura ---
export function ReadingProgress() {
// scrollYProgress: 0 (topo) → 1 (fim da página)
const { scrollYProgress } = useScroll();
// useSpring suaviza o valor do scroll (sem isso, a barra pode "pular")
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
return (
<motion.div
style={{
scaleX, // transform-origin: left por padrão
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 3,
background: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
transformOrigin: '0%',
}}
/>
);
}
// --- Exemplo 2: Parallax em seção ---
export function ParallaxSection() {
const ref = useRef<HTMLDivElement>(null);
// Rastreia o scroll relativo ao container, não à página inteira
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'], // Começa quando entra na viewport, termina quando sai
});
// Mapeia 0→1 do scroll para -50px→50px no eixo Y (parallax)
const y = useTransform(scrollYProgress, [0, 1], ['-15%', '15%']);
// Fade in ao entrar, fade out ao sair
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
return (
<div ref={ref} className="parallax-section">
<motion.div style={{ y, opacity }} className="parallax-content">
<h2>Seção com Parallax</h2>
<p>Este conteúdo se move a uma velocidade diferente do scroll.</p>
</motion.div>
</div>
);
}useScroll, useTransform e useSpring retornam MotionValues — um tipo especial do Framer Motion que atualiza sem causar re-renders do React. Por isso, scroll-driven animations com Framer Motion são extremamente eficientes: o valor muda dezenas de vezes por segundo durante o scroll, mas o componente React não re-renderiza.
Page Transitions no Next.js
Transições suaves entre páginas transformam um site comum em uma experiência de app. No Next.js App Router, você implementa isso no layout raiz:
// Componente wrapper para ser usado no layout.tsx do Next.js
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
const pageVariants = {
initial: { opacity: 0, y: 8 },
enter: {
opacity: 1,
y: 0,
transition: { duration: 0.3, ease: [0.61, 1, 0.88, 1] },
},
exit: {
opacity: 0,
y: -8,
transition: { duration: 0.2, ease: 'easeIn' },
},
};
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.main
key={pathname} // Chave muda na navegação, forçando exit + enter
variants={pageVariants}
initial="initial"
animate="enter"
exit="exit"
>
{children}
</motion.main>
</AnimatePresence>
);
}
// No layout.tsx:
// <PageTransition>{children}</PageTransition>Performance: Mantendo 60fps
Animações mal escritas causam jank — frames perdidos que fazem a interface travar. A regra de ouro é: anime apenas propriedades compostas (composited properties), que são processadas pela GPU sem envolver o layout do browser:
- ✅ `transform` (
translateX,translateY,scale,rotate) — processado na GPU - ✅ `opacity` — processado na GPU
- ❌ `width`, `height`, `margin`, `padding`, `top`, `left` — causam layout/reflow em cada frame, muito caro
- ❌ `background-color`, `color`, `border` — causam repaint, moderadamente caro
import { motion } from 'framer-motion';
// ❌ RUIM: animar width causa reflow em todo o layout a cada frame
const BadExpandButton = () => (
<motion.div
initial={{ width: 48 }}
animate={{ width: 200 }}
className="btn"
>
Expandir
</motion.div>
);
// ✅ BOM: animar scaleX com transform-origin não afeta o layout
const GoodExpandButton = () => (
<motion.div
initial={{ scaleX: 0.24 }} // 48/200 = 0.24
animate={{ scaleX: 1 }}
style={{ transformOrigin: 'left center', width: 200 }}
className="btn"
>
Expandir
</motion.div>
);
// Dica: adicione will-change='transform' no CSS para elementos que
// serão animados frequentemente. O browser cria uma layer compositor
// dedicada, isolando a animação do resto do layout.
//
// Mas use com moderação: cada layer compositor consome memória de GPU.Para identificar problemas de performance, abra o Chrome DevTools → Performance → grave uma interação e procure por Long Tasks (blocos vermelhos) e Layout Shift no painel de frames. O Framer Motion DevTools (extensão do Chrome) também mostra o estado de cada MotionValue em tempo real.
Gestures Avançados: Drag e Swipe
O Framer Motion inclui um sistema de gestures completo, com suporte a drag, pan e swipe com física. Útil para carrosséis, cards descartáveis (estilo Tinder) e UI mobile-first:
import { motion, useMotionValue, useTransform, animate } from 'framer-motion';
interface SwipeCardProps {
title: string;
onSwipeLeft: () => void;
onSwipeRight: () => void;
}
export function SwipeCard({ title, onSwipeLeft, onSwipeRight }: SwipeCardProps) {
const x = useMotionValue(0);
// Rotaciona o card proporcionalmente ao deslocamento horizontal
const rotate = useTransform(x, [-200, 0, 200], [-25, 0, 25]);
// Desbota o card ao se afastar do centro
const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0, 1, 1, 1, 0]);
async function handleDragEnd() {
const currentX = x.get();
const THRESHOLD = 100;
if (currentX > THRESHOLD) {
// Swipe direita: lança o card para fora da tela
await animate(x, 500, { type: 'spring', stiffness: 300, damping: 30 });
onSwipeRight();
} else if (currentX < -THRESHOLD) {
// Swipe esquerda
await animate(x, -500, { type: 'spring', stiffness: 300, damping: 30 });
onSwipeLeft();
} else {
// Não passou do threshold: volta para o centro
animate(x, 0, { type: 'spring', stiffness: 300, damping: 30 });
}
}
return (
<motion.div
style={{ x, rotate, opacity }}
drag="x"
dragConstraints={{ left: 0, right: 0 }} // Fica ancorado ao centro
dragElastic={0.8} // Resistência ao arrastar além dos constraints
onDragEnd={handleDragEnd}
className="swipe-card"
whileDrag={{ cursor: 'grabbing' }}
>
<h3>{title}</h3>
</motion.div>
);
}Conclusão
O Framer Motion tem uma curva de aprendizado progressiva: você começa com initial/animate em minutos e vai descobrindo features conforme a necessidade — Variants para consistência, AnimatePresence para unmount, MotionValues para performance, layout animations para reordenação. Cada camada adiciona expressividade sem comprometer a legibilidade do código.
A diferença entre uma animação mediana e uma premium está nos detalhes: usar spring ao invés de easing linear para interações do usuário, respeitar prefers-reduced-motion, animar apenas propriedades compostas e não animar o DOM diretamente via useEffect e style. Com o Framer Motion, a maioria dessas boas práticas já vem por padrão.