Antes de Framer Motion, pasaba demasiado tiempo peleando con @keyframes y transiciones CSS. Las animaciones interactivas — efectos hover, modales, transiciones de página — se volvían especialmente caóticas al mezclarlas con el manejo de estado de React.
Framer Motion cambió mi forma de pensar sobre las animaciones en React. Es declarativo, funciona de forma natural con el estado de React y gestiona las partes complicadas (como animar componentes al desmontarse) automáticamente. Este artículo cubre patrones prácticos desde fade-ins básicos hasta efectos activados por scroll y animaciones de layout.
Conceptos Fundamentales
Framer Motion se centra en el componente motion. Añade el prefijo motion. a cualquier elemento HTML y obtendrá capacidades de animación:
import { motion } from "framer-motion";
function FadeInBox() {
return (
<motion.div
initial={{ opacity: 0 }} // Starting state
animate={{ opacity: 1 }} // Target state
transition={{ duration: 0.5 }} // Animation settings
>
This fades in on mount
</motion.div>
);
}
Tres props que debes aprender:
initial— estado antes de que comience la animaciónanimate— estado objetivo al que se animatransition— duración, easing, delay, etc.
Propiedades de Animación Comunes
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.4,
ease: "easeOut",
delay: 0.1,
}}
>
Content
</motion.div>
Casi cualquier propiedad CSS funciona, pero prefiere opacity y propiedades de transform (x, y, scale, rotate). Están aceleradas por GPU y no provocan reflow del layout — manteniendo tus animaciones fluidas.
Patrones de Fade y Slide
Empezamos con los patrones más comunes.
Fade-in al Cargar la Página
"use client";
import { motion } from "framer-motion";
function HeroSection() {
return (
<section>
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
Page Title
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.15 }}
>
Subtext here
</motion.p>
<motion.button
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut", delay: 0.3 }}
>
Get Started
</motion.button>
</section>
);
}
Incrementar el delay crea un efecto stagger — los elementos aparecen secuencialmente.
Animaciones Hover
"use client";
import { motion } from "framer-motion";
function AnimatedCard({ title, description }: { title: string; description: string }) {
return (
<motion.div
className="rounded-lg border p-6 cursor-pointer"
whileHover={{
scale: 1.02,
boxShadow: "0 10px 40px rgba(0,0,0,0.15)",
}}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
<h3>{title}</h3>
<p>{description}</p>
</motion.div>
);
}
whileHover y whileTap son los equivalentes declarativos de CSS :hover y :active.
Variants para Animaciones Complejas
Cuando varios elementos se animan juntos, usa variants — estados de animación nombrados que se pueden compartir entre padre e hijos.
"use client";
import { motion } from "framer-motion";
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1, // Stagger children by 0.1s
delayChildren: 0.2, // Wait 0.2s before starting children
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
function AnimatedList({ items }: { items: string[] }) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
>
{items.map((item, i) => (
<motion.li key={i} variants={itemVariants}>
{item}
</motion.li>
))}
</motion.ul>
);
}
staggerChildren hace el trabajo pesado aquí. Cuando el contenedor transiciona a "visible", cada hijo inicia su propia animación con un desfase de delay. No necesitas gestionar delays manualmente — solo pasa los mismos variants a cada motion.li.
Animaciones Activadas por Scroll (whileInView)
whileInView activa una animación cuando el elemento entra en el viewport:
"use client";
import { motion } from "framer-motion";
function FeatureCard({ icon, title, description }: FeatureCardProps) {
return (
<motion.div
className="rounded-xl p-8 border"
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }} // Trigger 50px before fully in view
transition={{ duration: 0.5, ease: "easeOut" }}
>
<div className="text-4xl mb-4">{icon}</div>
<h3 className="text-xl font-bold mb-2">{title}</h3>
<p>{description}</p>
</motion.div>
);
}
Opciones de viewport:
once: true— anima solo la primera vez que entra en la vista (recomendado para revelaciones de contenido)margin— desplazamiento desde el borde del viewport; los valores negativos disparan antes de ser completamente visible
AnimatePresence: Animar Montaje y Desmontaje
Por defecto, React elimina componentes del DOM instantáneamente. AnimatePresence los mantiene en el DOM hasta que su animación de salida se completa.
"use client";
import { motion, AnimatePresence } from "framer-motion";
function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
key="overlay"
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
key="modal"
className="fixed inset-x-4 top-1/2 -translate-y-1/2 max-w-lg mx-auto
bg-white rounded-2xl p-8 shadow-2xl z-10"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: "spring", stiffness: 350, damping: 30 }}
>
{children}
<button onClick={onClose}>Close</button>
</motion.div>
</>
)}
</AnimatePresence>
);
}
La prop exit define la animación que se reproduce antes de que el componente sea eliminado. Sin AnimatePresence, exit se ignora.
Notificaciones Toast
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
type Toast = { id: number; message: string };
function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = (message: string) => {
const id = Date.now();
setToasts(prev => [...prev, { id, message }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 3000);
};
return (
<>
<button onClick={() => addToast("Saved!")}>Save</button>
<div className="fixed bottom-4 right-4 flex flex-col gap-2">
<AnimatePresence>
{toasts.map(toast => (
<motion.div
key={toast.id}
className="bg-gray-900 text-white px-4 py-3 rounded-lg shadow-lg"
initial={{ opacity: 0, x: 50, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 50, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
{toast.message}
</motion.div>
))}
</AnimatePresence>
</div>
</>
);
}
useAnimate para Secuencias Imperativas
Para animaciones secuenciales complejas o animaciones activadas en momentos específicos, useAnimate te da control imperativo:
"use client";
import { useAnimate } from "framer-motion";
function SubmitButton() {
const [scope, animate] = useAnimate();
const handleSubmit = async () => {
// 1. Press feedback
await animate(scope.current, { scale: 0.95 }, { duration: 0.1 });
await animate(scope.current, { scale: 1 }, { duration: 0.1 });
// 2. Loading state
animate(scope.current, { opacity: 0.7 });
try {
await submitForm();
// 3. Success feedback
await animate(scope.current, { backgroundColor: "#22c55e" }, { duration: 0.3 });
await animate(scope.current, { backgroundColor: "#3b82f6" }, { duration: 0.5 });
} catch {
// 4. Error shake
await animate(scope.current, { x: [-8, 8, -8, 8, 0] }, { duration: 0.4 });
}
};
return (
<button ref={scope} onClick={handleSubmit} className="px-6 py-3 bg-blue-500 rounded-lg">
Submit
</button>
);
}
animate devuelve una Promise, así que puedes usar await en cada paso y encadenarlos secuencialmente.
Animaciones de Layout con layoutId
layoutId anima suavemente un elemento de una posición a otra — incluso entre diferentes renderizados de componentes:
"use client";
import { motion, LayoutGroup } from "framer-motion";
import { useState } from "react";
function TabNav() {
const [activeTab, setActiveTab] = useState("home");
const tabs = ["home", "about", "contact"];
return (
<LayoutGroup>
<nav className="flex gap-4 relative">
{tabs.map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className="relative px-4 py-2"
>
{tab}
{activeTab === tab && (
<motion.div
layoutId="active-indicator"
className="absolute inset-0 bg-blue-100 rounded-md -z-10"
/>
)}
</button>
))}
</nav>
</LayoutGroup>
);
}
Cuando active-indicator se mueve a una pestaña diferente, Framer Motion automáticamente lo anima desde la posición anterior a la nueva. No necesitas rastrear coordenadas manualmente.
Buenas Prácticas de Rendimiento
Limítate a Transform y Opacity
// ❌ Causes layout reflow — expensive
<motion.div animate={{ width: "100%", height: 200, top: 50 }} />
// ✅ GPU-accelerated — no layout reflow
<motion.div animate={{ scaleX: 1, scaleY: 1, y: 50, opacity: 1 }} />
Cuando animas propiedades como width, height o top, el navegador recalcula el layout en cada frame. Limitarte a transform y opacity mantiene las animaciones en la GPU y fuera del hilo principal.
Movimiento Reducido para Accesibilidad
"use client";
import { motion, useReducedMotion } from "framer-motion";
function AnimatedCard({ children }: { children: React.ReactNode }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.4 }}
>
{children}
</motion.div>
);
}
useReducedMotion respeta la preferencia del usuario a nivel del sistema operativo para "Reducir movimiento". Siempre tenlo en cuenta para aplicaciones en producción.
Conclusión
Patrones principales de Framer Motion en una tabla:
| Patrón | Caso de Uso | Props Clave |
|---|---|---|
| Animación básica | Fade-in, slide | initial, animate, transition |
| Hover / tap | Feedback interactivo | whileHover, whileTap |
| Variants | Stagger en listas, animaciones coordinadas | variants, staggerChildren |
| Activado por scroll | Revelación de contenido | whileInView, viewport |
| Montaje / desmontaje | Modales, toasts | AnimatePresence, exit |
| Control secuencial | Coreografía compleja | useAnimate |
| Cambios de layout | Pestañas, reordenamiento de listas | layoutId, LayoutGroup |
La regla más importante: no sobre-animes. opacity + y cubre el 80% de los casos de forma elegante. Empieza simple, recurre a patrones complejos solo cuando sea necesario, y siempre mantén transform/opacity como tus propiedades principales de animación para el rendimiento.