32blogby StudioMitsu

Framer Motion: Guía de Animaciones en React

Desde fade-ins hasta efectos activados por scroll y transiciones de página — patrones prácticos de Framer Motion con ejemplos funcionales para React.

9 min read
ReactFramer MotionanimationCSSUX
Contenido

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:

tsx
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ón
  • animate — estado objetivo al que se anima
  • transition — duración, easing, delay, etc.

Propiedades de Animación Comunes

tsx
<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

tsx
"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

tsx
"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.

tsx
"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:

tsx
"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.

tsx
"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

tsx
"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:

tsx
"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:

tsx
"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

tsx
// ❌ 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

tsx
"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ónCaso de UsoProps Clave
Animación básicaFade-in, slideinitial, animate, transition
Hover / tapFeedback interactivowhileHover, whileTap
VariantsStagger en listas, animaciones coordinadasvariants, staggerChildren
Activado por scrollRevelación de contenidowhileInView, viewport
Montaje / desmontajeModales, toastsAnimatePresence, exit
Control secuencialCoreografía complejauseAnimate
Cambios de layoutPestañas, reordenamiento de listaslayoutId, 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.