Padrões de Animação (Motion Patterns)
Padrões de copiar e colar para as necessidades mais comuns de animação em UI.
Cada padrão aqui é construído sobre os tokens e molas do motion-foundations.
Não defina novos valores de duração ou easing aqui — importe-os.
Quando Ativar
- Animando um botão, cartão, modal ou notificação toast
- Construindo entradas de lista com escalonamento (stagger)
- Configurando transições de página no Next.js App Router
- Adicionando animações de entrada ou saída em conteúdo condicional
- Implementando animações de revelação no scroll, progresso vinculado ao scroll ou seções de história fixas
- Construindo cartões expansíveis, acordeões ou transições de elemento compartilhado
Resultados
Este skill produz:
- Animações acessíveis e seguras para SSR para todos os componentes de UI padrão
- Renders condicionais encapsulados com
AnimatePresencecom comportamento de saída correto - Componente wrapper de transição de página para o Next.js App Router
- Padrões de revelação no scroll e vinculados ao scroll usando
useScroll+useTransform - Padrões de animação de layout (
layout,layoutId) para elementos expansíveis e com crossfade
Princípios
- Cada padrão importa de
motion-foundations. Sem números brutos. - Cada render condicional é encapsulado em
AnimatePresencecom umakey. - Animações de saída são sempre definidas junto com as de entrada — nunca como reflexão tardia.
layouté usado apenas para pequenas mudanças isoladas. Subárvores grandes recebem transformações explícitas.
Regras
- Sempre encapsule renders condicionais em
AnimatePresencecom umakeyno filho direto. Sem uma key, as animações de saída nunca são executadas. - Sempre defina
exitao definirinitial+animate. Uma animação sem saída está incompleta. - Use
mode="wait"em transições de página. A entrada não deve começar até que a saída termine. - Nunca use
layoutem subárvores com mais de ~5 filhos ou DOM profundamente aninhado. Use transformações explícitas comx/y. - O intervalo de escalonamento (stagger) deve ficar entre
0.05se0.10s. Abaixo disso parece mecânico; acima parece lento. - Modais devem sempre incluir: armadilha de foco (focus trap), fechamento com tecla Escape, bloqueio de scroll,
role="dialog",aria-modal="true". - Revelações no scroll usam
viewport={{ once: true }}. Repetir ao sair do scroll é distrativo, não informativo. - Todos os valores de token são importados de
motion-foundations. Sem números inline.
Orientação de Decisão
Escolhendo o padrão certo
| Situação | Padrão |
|---|---|
| Elemento aparece / desaparece | AnimatePresence |
| Lista de itens carregando em sequência | Variantes com stagger |
| Navegando entre rotas | Wrapper de transição de página |
| Elemento muda de tamanho no lugar | Propriedade layout |
| Mesmo elemento se move entre contextos de página | layoutId |
| Elemento entra quando visível no scroll | whileInView |
| Valor vinculado à posição do scroll | useScroll + useTransform |
Quando usar mode="wait" vs mode="sync"
| Modo | Use quando |
|---|---|
wait | Transições de página, trocas de conteúdo (um de cada vez) |
sync | Notificações empilhadas, itens de lista (sobreposição é aceitável) |
popLayout | Itens removidos de uma lista com reflow |
Conceitos Principais
Contrato do AnimatePresence
Três coisas devem ser sempre verdade:
AnimatePresenceencapsula o condicional- O filho direto tem uma
key - O filho tem uma propriedade
exit
Se algum destes faltar, a animação de saída falha silenciosamente.
layout vs layoutId
layout— anima a própria mudança de tamanho/posição do elemento no lugarlayoutId— conecta dois elementos separados, fazendo crossfade entre eles em diferentes renders
Use layout="position" em texto dentro de um contêiner expansível para evitar que o reflow do texto seja animado.
Exemplos de Código
Feedback de botão
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.button
whileHover={{ scale: motionTokens.scale.pop }}
whileTap={{ scale: motionTokens.scale.press }}
transition={springs.snappy}
/>
Lista com escalonamento (stagger)
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08, // dentro da regra 0.05–0.10
delayChildren: 0.1,
},
},
}
const item = {
hidden: { opacity: 0, y: motionTokens.distance.md },
visible: { opacity: 1, y: 0, transition: springs.gentle },
}
<motion.ul variants={container} initial="hidden" animate="visible">
{items.map((i) => (
<motion.li key={i.id} variants={item} />
))}
</motion.ul>
Modal
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
// Encapsule no local de chamada:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
export function Modal({ onClose }: { onClose: () => void }) {
return (
<>
{/* Overlay */}
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Painel — requisitos de acessibilidade: focus trap, fechamento com Escape,
bloqueio de scroll, role="dialog", aria-modal="true" */}
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"
initial={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
transition={springs.gentle}
/>
</>
)
}
Pilha de toasts
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<AnimatePresence mode="sync">
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
transition={springs.snappy}
/>
))}
</AnimatePresence>
Transição de página (Next.js App Router)
// components/page-transition.tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
import { motionTokens } from "@/lib/motion-tokens"
const variants = {
initial: { opacity: 0, y: motionTokens.distance.sm },
enter: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -motionTokens.distance.sm },
}
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
variants={variants}
initial="initial"
animate="enter"
exit="exit"
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
>
{children}
</motion.div>
</AnimatePresence>
)
}
Revelação no scroll
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<motion.div
initial={{ opacity: 0, y: motionTokens.distance.lg }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }} // once: true — regra 7
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>
Barra de progresso do scroll
"use client"
import { motion, useScroll } from "motion/react"
export function ScrollProgress() {
const { scrollYProgress } = useScroll()
return (
<motion.div
className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"
style={{ scaleX: scrollYProgress }}
/>
)
}
Cartão expansível
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
export function ExpandingCard({ title, body }: { title: string; body: string }) {
const [expanded, setExpanded] = useState(false)
return (
<motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">
{/* layout="position" impede que o reflow do texto seja animado */}
<motion.h2 layout="position" className="font-semibold">
{title}
</motion.h2>
<AnimatePresence>
{expanded && (
<motion.p
key="body"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
{body}
</motion.p>
)}
</AnimatePresence>
</motion.div>
)
}
Crossfade de elemento compartilhado
// Contexto de origem
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />
// Contexto de destino (mesmo layoutId — motion lida com a transição)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />
Acordeão
<motion.div
initial={false}
animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
style={{ transformOrigin: "top", overflow: "hidden" }}
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
> {children}
</motion.div>
Exemplo Completo
Uma lista com escalonamento que entra na montagem, lida com presença condicional e respeita movimento reduzido — combinando tokens, molas, AnimatePresence e o hook de acessibilidade do motion-foundations:
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"
const containerVariants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
}
function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {
const safe = useSafeMotion(motionTokens.distance.sm)
return (
<motion.li
variants={{
hidden: safe.initial,
visible: safe.animate,
}}
exit={safe.exit}
transition={springs.gentle}
className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"
>
<span>{label}</span>
<button onClick={onRemove}>Remover</button>
</motion.li>
)
}
export function AnimatedList({ items, onRemove }: {
items: { id: string; label: string }[]
onRemove: (id: string) => void
}) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-2"
>
<AnimatePresence mode="popLayout">
{items.map((item) => (
<ListItem
key={item.id}
label={item.label}
onRemove={() => onRemove(item.id)}
/>
))}
</AnimatePresence>
</motion.ul>
)
}
Restrições / Não-Objetivos
Este skill não cobre:
- Definições de tokens e molas → veja
motion-foundations - Interações de arrastar, gestos de deslizar, listas reordenáveis → veja
motion-advanced - Animações de texto (revelação de palavras/caracteres, contadores) → veja
motion-advanced - Desenho de caminhos SVG ou morphing → veja
motion-advanced - Hooks de animação personalizados → veja
motion-advanced - Transições apenas em CSS que não usam
motion/react
Anti-Padrões
| Anti-padrão | Regra violada | Correção |
|---|---|---|
Filho de AnimatePresence sem key | Regra 1 | Adicione key estável ao filho direto |
initial + animate sem exit | Regra 2 | Sempre defina os três juntos |
Transição de página sem mode="wait" | Regra 3 | Adicione mode="wait" ao AnimatePresence |
layout em uma lista de 50 itens | Regra 4 | Use mode="popLayout" ou transformações explícitas |
staggerChildren: 0.2 em uma lista de 10 itens | Regra 5 | Limite a 0.08–0.10 |
| Modal sem armadilha de foco | Regra 6 | Adicione focus-trap-react ou Radix Dialog |
whileInView sem viewport={{ once: true }} | Regra 7 | Entradas repetitivas distraem, não informam |
transition={{ duration: 0.3 }} inline | Regra 8 | Use motionTokens.duration.normal |
Skills Relacionados
motion-foundations— define todos os tokens, molas, o hookuseSafeMotione proteções SSR que todo padrão aqui importa. Deve ser configurado primeiro.motion-advanced— estende estes padrões com arrastar, gestos, SVG, texto, hooks personalizados e sequenciamento imperativo. Não redefine nenhum padrão deste skill.