5 Animaciones con Framer Motion que Uso en Todos Mis Proyectos React
Las 5 animaciones de Framer Motion que copié-pegué en mis últimos 4 proyectos React/Next.js. Fade-in al scroll, page transitions, modales, skeleton loaders y stagger lists — con código completo.
Tabla de contenidos
Cada proyecto React que hago tiene las mismas 5 animaciones. Al principio las implementaba desde cero cada vez, hasta que me hice un mini-kit de componentes reutilizables con Framer Motion.
No son animaciones flashy. Son las típicas que hacen que una web pase de “funcional” a “se siente premium”. La diferencia entre una web de alguien que acaba de aprender React y una web profesional suele ser esto: las microinteracciones.
Instalación
npm install framer-motion
1. Fade-in al hacer scroll (el más usado)
Esta es la animación que más uso. Los elementos aparecen suavemente cuando entran en el viewport al hacer scroll.
'use client';
import { motion } from 'framer-motion';
interface FadeInProps {
children: React.ReactNode;
delay?: number;
direction?: 'up' | 'down' | 'left' | 'right';
className?: string;
}
export function FadeIn({ children, delay = 0, direction = 'up', className }: FadeInProps) {
const directionOffset = {
up: { y: 40 },
down: { y: -40 },
left: { x: 40 },
right: { x: -40 },
};
return (
<motion.div
initial={{ opacity: 0, ...directionOffset[direction] }}
whileInView={{ opacity: 1, x: 0, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className={className}
>
{children}
</motion.div>
);
}
Uso:
<FadeIn>
<h2>Este título aparece suavemente</h2>
</FadeIn>
<FadeIn delay={0.2} direction="left">
<Card />
</FadeIn>
Por qué funciona: viewport: { once: true } hace que la animación solo se ejecute una vez (no cada vez que scrolleas arriba y abajo). El margin: '-100px' hace que se active un poco antes de que el elemento sea visible, para que el usuario ya lo vea animándose, no apareciendo de golpe.
Error que cometí: Al principio usaba duration: 1.5 pensando que más lento = más elegante. No. Las animaciones largas se sienten lentas y molestas. 0.3 - 0.6 segundos es el sweet spot.
2. Stagger List (items que aparecen en cascada)
Cuando tienes una lista de cards, features, o cualquier grupo de elementos, hacer que aparezcan uno detrás de otro queda genial.
'use client';
import { motion } from 'framer-motion';
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: 'easeOut' },
},
};
interface StaggerListProps {
children: React.ReactNode;
className?: string;
}
export function StaggerList({ children, className }: StaggerListProps) {
return (
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-50px' }}
className={className}
>
{children}
</motion.div>
);
}
export function StaggerItem({ children, className }: StaggerListProps) {
return (
<motion.div variants={itemVariants} className={className}>
{children}
</motion.div>
);
}
Uso:
<StaggerList className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StaggerItem><FeatureCard title="Auth" /></StaggerItem>
<StaggerItem><FeatureCard title="Pagos" /></StaggerItem>
<StaggerItem><FeatureCard title="Dashboard" /></StaggerItem>
</StaggerList>
El truco: staggerChildren: 0.1 significa que cada hijo espera 0.1s más que el anterior. Con 3 items el efecto es sutil pero elegante. Con 10+ items, sube a 0.05 para que no tarde siglos.
3. Modal con backdrop animado
Los modales sin animación se sienten rotos. Con Framer Motion + AnimatePresence, entran y salen suavemente.
'use client';
import { motion, AnimatePresence } from 'framer-motion';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div
className="w-full max-w-lg rounded-2xl bg-white dark:bg-gray-900 p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
Uso:
const [isOpen, setIsOpen] = useState(false);
<button onClick={() => setIsOpen(true)}>Abrir modal</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2>Confirmar acción</h2>
<p>¿Estás seguro?</p>
<button onClick={() => setIsOpen(false)}>Cerrar</button>
</Modal>
Detalle clave: AnimatePresence es lo que permite la animación de salida (exit). Sin él, React elimina el componente del DOM inmediatamente y nunca ves la animación de cierre. Me costó un bug de 2 horas descubrir esto la primera vez.
El backdrop con backdrop-blur-sm es ese efecto de desenfoque que usan las apps de Apple. Se ve premium y cuesta 0 esfuerzo extra.
4. Skeleton Loader animado
Los skeleton loaders (esos rectángulos grises pulsantes mientras carga el contenido) son estándar en 2026. Pero los de CSS puro se ven planos. Con Framer Motion quedan más suaves.
'use client';
import { motion } from 'framer-motion';
export function Skeleton({ className }: { className?: string }) {
return (
<motion.div
className={`rounded-lg bg-gray-200 dark:bg-gray-800 ${className}`}
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
/>
);
}
// Ejemplo: Skeleton de una card
export function CardSkeleton() {
return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 p-6 space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
);
}
Uso:
function PostList() {
const { data: posts, isLoading } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{posts.map(post => <PostCard key={post.id} post={post} />)}
</div>
);
}
Tip: El skeleton debe tener la misma estructura visual que el contenido real. Si tu card tiene imagen arriba + título + 2 líneas de texto + tags, tu skeleton debe tener las mismas formas. Si no, hay un “salto” feo cuando se carga el contenido.
5. Transiciones entre páginas (Next.js App Router)
Esta es la más compleja pero la que más impacto visual tiene. Cuando navegas entre páginas, en vez de un corte brusco, el contenido sale suavemente y el nuevo entra.
// app/template.tsx (NO layout.tsx — template se re-renderiza en cada navegación)
'use client';
import { motion } from 'framer-motion';
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
{children}
</motion.div>
);
}
Importante: Usa template.tsx, no layout.tsx. Los layouts en Next.js no se re-renderizan entre navegaciones — persisten. El template.tsx sí se re-renderiza, que es lo que necesitas para que la animación se dispare.
Versión con exit animation (más compleja, requiere AnimatePresence en el layout):
// app/layout.tsx
'use client'; // Solo si necesitas AnimatePresence
import { AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<html lang="es">
<body>
<Navbar />
<AnimatePresence mode="wait">
<motion.main
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{children}
</motion.main>
</AnimatePresence>
</body>
</html>
);
}
Ojo: Esta versión convierte el layout en Client Component, lo que deshabilita Server Components para todo el tree. Para la mayoría de landing pages no importa. Para apps con mucho data fetching en el servidor, usa la versión template.tsx que es más simple y no tiene este trade-off.
Bonus: Hover scale para cards/botones
Este no cuenta como “animación” pero lo pongo en todo:
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="cursor-pointer"
>
<Card />
</motion.div>
whileHover: { scale: 1.02 } es sutil pero se siente responsive. El whileTap: { scale: 0.98 } da feedback táctil al hacer clic. La transición con spring se siente más natural que easeOut.
Mi checklist antes de animar
- ¿La animación tiene propósito? Si es decorativa y no ayuda al usuario a entender qué pasa → la quito
- ¿Dura menos de 0.5s? Las animaciones de UI no deberían durar más (las de scroll pueden llegar a 0.6)
- ¿Respeta
prefers-reduced-motion? Siempre:
// En tu CSS global o Tailwind config
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
- ¿La animación se ejecuta solo una vez?
viewport: { once: true }para scroll animations - ¿El contenido es usable sin la animación? Si JavaScript falla, el contenido debe ser visible
Artículos relacionados
- 10 Ejemplos de Landing Pages con Tailwind CSS — estos diseños con estas animaciones
- Cursor vs Copilot vs Windsurf — las herramientas que uso para codear más rápido
- Cheat sheet de CSS Grid y Flexbox — los layouts que animo con Framer Motion