Saltar al contenido principal

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.

Fran Cobos 8 min de lectura 1463 palabras

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

  1. ¿La animación tiene propósito? Si es decorativa y no ayuda al usuario a entender qué pasa → la quito
  2. ¿Dura menos de 0.5s? Las animaciones de UI no deberían durar más (las de scroll pueden llegar a 0.6)
  3. ¿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;
  }
}
  1. ¿La animación se ejecuta solo una vez? viewport: { once: true } para scroll animations
  2. ¿El contenido es usable sin la animación? Si JavaScript falla, el contenido debe ser visible

Artículos relacionados

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.