Home / Frontend / Motion en Next.js 16: animaciones, gestos y transiciones de ruta
Motion en Next.js 16: animaciones, gestos y transiciones de ruta

Motion en Next.js 16: animaciones, gestos y transiciones de ruta

14 November 2026 · 27 min de lectura ··

Por qué vale la pena animar una aplicación Next.js

Una interfaz que responde con movimiento comunica mucho más que una que simplemente cambia de estado. Cuando una tarjeta se eleva ligeramente al pasar el cursor, cuando un listado aparece elemento por elemento o cuando una página se desvanece con suavidad antes de mostrar la siguiente, el usuario entiende qué está pasando sin necesidad de leer nada. Las animaciones bien diseñadas orientan, confirman acciones y dan una sensación de calidad que es difícil de lograr solo con diseño estático.

En el ecosistema de React, la librería más utilizada para este trabajo es Motion, que durante años se conoció como Framer Motion. El cambio de nombre no fue solo cosmético: el proyecto se independizó, el paquete pasó a llamarse motion y la importación para React ahora se hace desde motion/react. Si tienes proyectos antiguos con framer-motion, siguen funcionando, pero la recomendación actual es migrar al paquete nuevo.

En este tutorial construiremos desde cero un catálogo de recetas llamado Recetario con Next.js 16 y el App Router, y le añadiremos animaciones de forma progresiva: entrada de elementos, gestos de hover y tap, variants reutilizables, keyframes, animaciones de salida con AnimatePresence, transiciones entre páginas y, finalmente, el soporte para View Transitions que ya ofrecen React y Next.js. También dedicaremos secciones completas a la accesibilidad, al rendimiento y a los errores más habituales.

Las versiones estables que usaremos, confirmadas en el registro de npm al momento de escribir, son:

  • Next.js 16.4.6.
  • React 19.3.1.
  • Motion 14.14.0.

Si ya trabajaste con animaciones en React y quieres comparar enfoques ligeros frente a soluciones completas, te recomiendo revisar también este artículo sobre animaciones en aplicaciones React con AutoAnimate y Motion, que complementa lo que veremos aquí.

Qué cambió respecto a los tutoriales antiguos

Muchos ejemplos que circulan en internet fueron escritos para una época en la que Next.js solo tenía el Pages Router. En esos tutoriales las transiciones de página se resolvían en pages/_app.js, usando el objeto router para generar una key distinta en cada ruta. Ese modelo ya no aplica al App Router por varias razones:

  • No existe _app.js. En su lugar tenemos layout.tsx, que persiste entre navegaciones, y template.tsx, que se vuelve a montar en cada cambio de ruta.
  • Los componentes son de servidor por defecto. Motion depende de efectos, refs y APIs del navegador, así que cualquier componente que use motion.div debe ser un componente cliente marcado con la directiva "use client".
  • El hook useRouter de next/router fue reemplazado por los hooks de next/navigation, como usePathname y useRouter.
  • El paquete framer-motion evolucionó hacia motion, con imports desde motion/react.
  • React 19.3 incorporó el componente <ViewTransition> y Next.js 16.4 lo integra con la navegación del App Router sin necesidad de configuración adicional.

Tener claras estas diferencias evita uno de los problemas más comunes: copiar código del Pages Router en un proyecto moderno y descubrir que nada se anima o que aparecen errores de hidratación.

Crear el proyecto Recetario

Empezamos generando el proyecto con el asistente oficial. Acepta las opciones por defecto, que incluyen TypeScript, ESLint, Tailwind CSS, el App Router y Turbopack:

npx create-next-app@latest recetario
cd recetario

Después instalamos Motion. Observa que el paquete es motion, no framer-motion:

npm install motion

Puedes comprobar las versiones instaladas con:

npm ls next react motion

Una salida típica sería similar a esta:

La estructura que construiremos a lo largo del tutorial es la siguiente:

recetario/
├── app/
│   ├── layout.tsx
│   ├── template.tsx
│   ├── page.tsx
│   ├── globals.css
│   └── recetas/
│       └── [slug]/
│           └── page.tsx
├── components/
│   ├── AnimatedHeading.tsx
│   ├── RecipeGrid.tsx
│   ├── RecipeCard.tsx
│   ├── FavoriteButton.tsx
│   ├── FilterPanel.tsx
│   └── MotionProvider.tsx
└── lib/
    └── recetas.ts

Los datos de ejemplo vivirán en un archivo sencillo, sin base de datos, para centrarnos en las animaciones:

// lib/recetas.ts
export type Receta = {
  slug: string;
  titulo: string;
  tiempo: number;
  dificultad: "fácil" | "media" | "difícil";
  categoria: "desayuno" | "principal" | "postre";
  descripcion: string;
};

export const recetas: Receta[] = [
  {
    slug: "tortilla-de-patatas",
    titulo: "Tortilla de patatas",
    tiempo: 40,
    dificultad: "media",
    categoria: "principal",
    descripcion: "Patatas confitadas, huevo y un punto jugoso en el centro.",
  },
  {
    slug: "chilaquiles-verdes",
    titulo: "Chilaquiles verdes",
    tiempo: 25,
    dificultad: "fácil",
    categoria: "desayuno",
    descripcion: "Totopos crujientes bañados en salsa de tomate verde.",
  },
  {
    slug: "arroz-con-leche",
    titulo: "Arroz con leche",
    tiempo: 50,
    dificultad: "fácil",
    categoria: "postre",
    descripcion: "Cremoso, con canela y ralladura de limón.",
  },
  {
    slug: "ceviche-peruano",
    titulo: "Ceviche peruano",
    tiempo: 30,
    dificultad: "media",
    categoria: "principal",
    descripcion: "Pescado fresco curado en limón con ají y cebolla morada.",
  },
];

export function obtenerReceta(slug: string) {
  return recetas.find((r) => r.slug === slug);
}

La ruta de detalle recetas/[slug] es un segmento dinámico. Si necesitas repasar cómo funcionan estos segmentos, generateStaticParams y el acceso a los parámetros, tienes una explicación completa en la guía de configuración de enrutamiento dinámico en Next.js.

Componentes de servidor y componentes cliente con Motion

Antes de escribir la primera animación conviene entender dónde puede vivir el código de Motion. En el App Router, app/page.tsx es un componente de servidor: se renderiza en el servidor, no envía su JavaScript al navegador y no puede usar estado ni efectos. Motion, en cambio, necesita ejecutarse en el cliente para medir elementos, escuchar gestos y actualizar estilos en cada frame.

La estrategia recomendada es mantener las páginas como componentes de servidor que obtienen datos y delegar la parte animada a componentes cliente pequeños. Así reduces el JavaScript enviado y conservas las ventajas del renderizado en servidor.

// components/AnimatedHeading.tsx
"use client";

import { motion } from "motion/react";

export function AnimatedHeading({ children }: { children: React.ReactNode }) {
  return (
    <motion.h1
      className="text-4xl font-bold tracking-tight"
      initial={{ opacity: 0, y: 24 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.6, ease: "easeOut" }}
    >
      {children}
    </motion.h1>
  );
}

La página principal sigue siendo de servidor y simplemente usa el componente:

// app/page.tsx
import { AnimatedHeading } from "@/components/AnimatedHeading";
import { RecipeGrid } from "@/components/RecipeGrid";
import { recetas } from "@/lib/recetas";

export default function Home() {
  return (
    <main className="mx-auto max-w-5xl p-8">
      <AnimatedHeading>Recetario</AnimatedHeading>
      <p className="mt-2 text-neutral-600">
        Recetas caseras explicadas paso a paso.
      </p>
      <RecipeGrid recetas={recetas} />
    </main>
  );
}

Motion también ofrece el punto de entrada motion/react-client, que expone los componentes motion ya marcados como cliente. Permite escribir algo como import * as motion from "motion/react-client" dentro de un componente de servidor para animaciones simples sin crear un archivo aparte. Es cómodo para casos puntuales, pero en cuanto necesites hooks, estado o lógica de gestos, lo más limpio es un componente cliente dedicado.

Animaciones de entrada con initial y animate

El patrón básico de Motion se apoya en tres props. initial define el estado de partida, animate el estado final y transition cómo se recorre el camino entre ambos. Cuando el componente se monta, Motion interpola los valores automáticamente.

En el ejemplo anterior el título parte con opacidad cero y desplazado 24 píxeles hacia abajo, y termina en su posición natural. Algunos detalles importantes:

  • Las propiedades x, y, scale y rotate son atajos de transformaciones CSS. Motion las combina en una sola propiedad transform, lo que resulta muy eficiente.
  • Si no indicas transition, Motion elige valores por defecto razonables: animaciones tipo resorte para propiedades físicas como x o scale, y curvas de tiempo para propiedades como opacity o colores.
  • Puedes pasar initial={false} para que el componente aparezca directamente en el estado de animate, útil cuando no quieres animar en la primera carga pero sí en cambios posteriores.

Las transiciones tipo resorte merecen atención especial porque dan una sensación muy natural:

<motion.div
  initial={{ scale: 0.8, opacity: 0 }}
  animate={{ scale: 1, opacity: 1 }}
  transition={{ type: "spring", stiffness: 260, damping: 20 }}
/>

Con stiffness controlas la rigidez del resorte y con damping cuánto se amortigua el rebote. Valores altos de damping eliminan la oscilación; valores bajos producen un efecto más juguetón. También existe la opción bounce combinada con duration, que resulta más intuitiva para quien prefiere pensar en tiempos en lugar de física.

Variants para orquestar el listado de recetas

Escribir objetos de animación directamente en cada componente funciona en ejemplos pequeños, pero se vuelve difícil de mantener cuando varios elementos comparten estados. Las variants resuelven esto: son objetos con nombres de estados que puedes reutilizar y, lo más interesante, propagar de padres a hijos.

Cuando un componente padre usa initial="oculto" y animate="visible", todos sus hijos motion que definan variants con esos mismos nombres heredan el estado sin necesidad de repetir las props. Esto permite crear animaciones de entrada escalonadas en un listado con muy poco código.

// components/RecipeGrid.tsx
"use client";

import { motion, stagger, type Variants } from "motion/react";
import type { Receta } from "@/lib/recetas";
import { RecipeCard } from "./RecipeCard";

const contenedor: Variants = {
  oculto: {},
  visible: {
    transition: { delayChildren: stagger(0.08) },
  },
};

export const tarjeta: Variants = {
  oculto: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.4, ease: "easeOut" },
  },
};

export function RecipeGrid({ recetas }: { recetas: Receta[] }) {
  return (
    <motion.ul
      className="mt-8 grid gap-6 sm:grid-cols-2"
      variants={contenedor}
      initial="oculto"
      animate="visible"
    >
      {recetas.map((receta) => (
        <RecipeCard key={receta.slug} receta={receta} />
      ))}
    </motion.ul>
  );
}

La función stagger aplicada a delayChildren reparte un retraso creciente entre los hijos: el primero empieza enseguida, el segundo 80 milisegundos después, y así sucesivamente. Acepta opciones como from: "last" para invertir el orden o from: "center" para que la animación se expanda desde el centro del listado. En versiones antiguas de la librería esto se hacía con la propiedad staggerChildren; en la API actual la forma documentada es delayChildren: stagger(...).

Un buen hábito es definir las variants fuera del componente. Así no se crean objetos nuevos en cada render y puedes exportarlas para reutilizarlas en otras partes de la aplicación, como hicimos con tarjeta.

Gestos de hover, tap y foco en las tarjetas

Motion incluye props específicas para gestos: whileHover, whileTap, whileFocus, whileDrag y whileInView. Mientras el gesto está activo, el elemento se anima hacia ese estado y, al terminar, vuelve automáticamente al estado de animate.

Construyamos la tarjeta de receta combinando la variant heredada con efectos de interacción:

// components/RecipeCard.tsx
"use client";

import Link from "next/link";
import { motion } from "motion/react";
import type { Receta } from "@/lib/recetas";
import { tarjeta } from "./RecipeGrid";

export function RecipeCard({ receta }: { receta: Receta }) {
  return (
    <motion.li
      variants={tarjeta}
      whileHover={{ y: -6, boxShadow: "0 12px 24px rgba(0,0,0,0.12)" }}
      whileTap={{ scale: 0.97 }}
      transition={{ type: "spring", stiffness: 300, damping: 22 }}
      className="relative rounded-2xl border border-neutral-200 bg-white p-6"
    >
      <Link href={`/recetas/${receta.slug}`} className="block focus:outline-none">
        <span className="text-xs uppercase text-orange-600">
          {receta.categoria}
        </span>
        <h2 className="mt-1 text-xl font-semibold">{receta.titulo}</h2>
        <p className="mt-2 text-sm text-neutral-600">{receta.descripcion}</p>
        <p className="mt-4 text-sm">
          {receta.tiempo} min · dificultad {receta.dificultad}
        </p>
      </Link>
    </motion.li>
  );
}

Hay varios detalles que conviene destacar:

  • La tarjeta se eleva con y: -6 en lugar de escalar. Escalar una tarjeta dentro de una cuadrícula puede hacer que se superponga a las vecinas; el desplazamiento vertical con sombra transmite la misma idea sin conflictos de apilamiento.
  • whileTap da una respuesta inmediata al hacer clic o tocar en móvil, lo que mejora la percepción de velocidad aunque la navegación tarde unos milisegundos.
  • La clase relative asegura que la tarjeta crea su propio contexto de posicionamiento, útil si añades elementos superpuestos como una insignia.

Los gestos de hover solo existen con puntero. Motion filtra los eventos táctiles para que whileHover no se quede pegado en móviles, un problema clásico de las pseudo-clases :hover de CSS en pantallas táctiles.

Para la accesibilidad con teclado puedes añadir whileFocus, aunque en este caso el foco cae en el enlace interior. Una alternativa es aplicar estilos de foco visibles con las utilidades de Tailwind sobre el enlace, de modo que la navegación con teclado sea tan clara como la del ratón.

Keyframes en un botón de favoritos

Hasta ahora cada propiedad iba de un valor inicial a uno final. Con keyframes puedes definir una secuencia completa pasando un array de valores. Motion recorre cada valor en orden, distribuyendo el tiempo de forma uniforme salvo que indiques otra cosa con la opción times.

Vamos a crear un botón para marcar recetas como favoritas que dé un pequeño latido al activarse:

// components/FavoriteButton.tsx
"use client";

import { useState } from "react";
import { motion } from "motion/react";

export function FavoriteButton() {
  const [favorita, setFavorita] = useState(false);

  return (
    <motion.button
      type="button"
      aria-pressed={favorita}
      aria-label={favorita ? "Quitar de favoritas" : "Guardar como favorita"}
      onClick={() => setFavorita((f) => !f)}
      animate={
        favorita
          ? { scale: [1, 1.35, 0.9, 1.1, 1], rotate: [0, -12, 10, -4, 0] }
          : { scale: 1, rotate: 0 }
      }
      transition={{ duration: 0.5, times: [0, 0.25, 0.5, 0.75, 1] }}
      whileTap={{ scale: 0.9 }}
      className="rounded-full p-3 text-2xl"
    >
      {favorita ? "♥" : "♡"}
    </motion.button>
  );
}

Cada vez que favorita cambia a true, el corazón crece, se encoge, rebota y gira de un lado a otro antes de volver a su tamaño. El array times indica en qué proporción de la duración total se alcanza cada valor, lo que te da control fino sobre el ritmo.

Un truco útil con keyframes es usar null como primer valor. Significa “parte del valor actual”, lo que evita saltos si la animación se interrumpe y se vuelve a disparar a mitad de camino:

<motion.div animate={{ x: [null, 40, 0] }} />

Los keyframes también sirven para efectos continuos, como un indicador de carga que pulsa mientras se obtienen datos:

<motion.span
  className="inline-block h-3 w-3 rounded-full bg-orange-500"
  animate={{ opacity: [0.3, 1, 0.3] }}
  transition={{ duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
/>

Úsalos con moderación: un elemento que se mueve indefinidamente atrae la atención de forma constante y puede resultar molesto o incluso problemático para personas sensibles al movimiento.

Animaciones de salida con AnimatePresence

React desmonta los componentes de inmediato cuando dejan de renderizarse, así que en principio no hay tiempo para animar su salida. AnimatePresence resuelve este problema: detecta cuándo un hijo directo desaparece del árbol, lo mantiene montado el tiempo necesario para ejecutar su animación exit y solo entonces lo elimina.

Vamos a añadir un panel de filtros que se despliega y se oculta con suavidad:

// components/FilterPanel.tsx
"use client";

import { useState } from "react";
import { AnimatePresence, motion } from "motion/react";

const categorias = ["desayuno", "principal", "postre"] as const;

export function FilterPanel() {
  const [abierto, setAbierto] = useState(false);

  return (
    <div className="mt-6">
      <button
        type="button"
        aria-expanded={abierto}
        aria-controls="panel-filtros"
        onClick={() => setAbierto((a) => !a)}
        className="rounded-lg bg-neutral-900 px-4 py-2 text-white"
      >
        {abierto ? "Ocultar filtros" : "Mostrar filtros"}
      </button>

      <AnimatePresence initial={false}>
        {abierto && (
          <motion.div
            id="panel-filtros"
            key="panel-filtros"
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.3, ease: "easeInOut" }}
            className="overflow-hidden"
          >
            <ul className="flex gap-3 pt-4">
              {categorias.map((c) => (
                <li key={c} className="rounded-full border px-3 py-1 text-sm">
                  {c}
                </li>
              ))}
            </ul>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Hay tres reglas que debes respetar para que AnimatePresence funcione:

  1. El elemento que entra y sale debe ser hijo directo de AnimatePresence, o estar dentro de un componente que sea hijo directo.
  2. Cada hijo necesita una key estable y única. Es la forma en que Motion sabe qué elemento salió y cuál entró.
  3. La condición que lo muestra u oculta debe estar dentro de AnimatePresence, no fuera. Si envuelves AnimatePresence en la condición, se desmonta junto con su hijo y la animación de salida nunca ocurre.

Animar height hacia "auto" es una de las ventajas de Motion frente a CSS puro, que tradicionalmente no podía interpolar hacia alturas automáticas. Ten en cuenta que animar la altura provoca recálculos de layout; en paneles pequeños no es un problema, pero en listas largas conviene recurrir a la prop layout o a transformaciones.

La prop mode controla cómo conviven los elementos que entran y salen. Con el valor por defecto, "sync", ambos se animan a la vez. Con "wait", el nuevo elemento espera a que termine la salida del anterior, ideal para reemplazar contenido en el mismo espacio. Con "popLayout", el elemento saliente se saca del flujo del documento para que los hermanos se reacomoden de inmediato, muy útil al eliminar elementos de una lista combinando AnimatePresence con la prop layout.

Transiciones de página con template.tsx

Llegamos a uno de los temas que más confusión genera, porque las transiciones de página en App Router no funcionan como en el modelo anterior. El primer recurso que ofrece Next.js es el archivo template.tsx. Funciona como un layout, pero con una diferencia clave: mientras que el layout se conserva entre navegaciones, el template crea una instancia nueva en cada cambio de ruta. Eso significa que sus componentes hijos se vuelven a montar y, por tanto, las animaciones de initial y animate se ejecutan de nuevo.

// app/template.tsx
"use client";

import { motion } from "motion/react";

export default function Template({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.35, ease: "easeOut" }}
    >
      {children}
    </motion.div>
  );
}

Con este archivo, cada vez que navegas de la portada a una receta o entre recetas, el contenido aparece con un desvanecimiento y un ligero desplazamiento vertical. Es simple, robusto y no depende de APIs internas.

El inconveniente es que el template solo resuelve la entrada. Cuando cambias de ruta, Next.js desmonta la instancia anterior inmediatamente, así que no hay oportunidad de ejecutar una animación exit. Para muchas aplicaciones esto es suficiente, porque una entrada suave ya elimina la sensación de salto brusco. Si necesitas animar la salida, tienes dos caminos que veremos a continuación.

Recuerda que el template también se aplica a las rutas anidadas. Si lo colocas en la raíz de app, afecta a todas las páginas; si lo colocas dentro de app/recetas, solo a esa sección. Esta granularidad es útil para animar únicamente las zonas de la aplicación donde el cambio de contenido es significativo.

AnimatePresence con usePathname y sus limitaciones

El segundo enfoque, heredado conceptualmente del Pages Router, consiste en envolver el contenido en AnimatePresence y usar la ruta actual como key. En el App Router la ruta se obtiene con el hook usePathname:

// components/PageTransition.tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import { usePathname } from "next/navigation";

export function PageTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={pathname}
        initial={{ opacity: 0, x: 24 }}
        animate={{ opacity: 1, x: 0 }}
        exit={{ opacity: 0, x: -24 }}
        transition={{ duration: 0.25 }}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  );
}

Este componente se usaría dentro de app/layout.tsx envolviendo a children. Funciona a primera vista, pero tiene una trampa: cuando la ruta cambia, el router de Next.js ya ha sustituido el contenido del segmento por la página nueva. El elemento que AnimatePresence mantiene montado para su salida se vuelve a renderizar con el contenido actualizado, así que lo que ves desvanecerse es la página nueva y no la anterior, provocando un parpadeo extraño.

La comunidad ha creado soluciones que “congelan” el contexto del router durante la salida, normalmente importando LayoutRouterContext desde rutas internas de Next.js. Funcionan, pero dependen de módulos no documentados que pueden cambiar en cualquier versión menor, por lo que no los recomiendo para producción salvo que estés dispuesto a mantener ese código en cada actualización.

En la práctica, el uso más sensato de AnimatePresence con usePathname está en zonas cuyo contenido controlas tú, no en el children que entrega el router. Por ejemplo, un indicador de sección en la barra de navegación que cambia de texto según la ruta, o un menú que se cierra con animación al navegar. Para animar la página completa, el template o las View Transitions son opciones más estables.

Si tu aplicación tiene navegación condicionada por permisos o menús que cambian según el usuario, combinar estas técnicas con lo explicado en el artículo sobre navegación dinámica en Next.js con rutas protegidas y rendering condicional te permitirá animar menús y redirecciones sin perder el control del estado.

View Transitions nativas con React 19.3 y Next.js 16

La tercera vía es la más prometedora a medio plazo. React 19.3 incluye el componente <ViewTransition>, que se importa directamente desde react y se apoya en la View Transitions API del navegador. La idea es distinta a la de Motion: en lugar de interpolar valores con JavaScript, el navegador toma una instantánea del estado anterior y del nuevo y anima entre ambos con CSS.

Un punto que conviene aclarar porque circula mucha información desactualizada: en versiones anteriores de Next.js existía la opción experimental.viewTransition en next.config. Esa bandera fue retirada en la rama 16.3, y la documentación de Next.js 16.4 indica que las View Transitions funcionan en el App Router sin configuración, siempre que uses React 19.3 o superior. Si tu proyecto todavía la tiene declarada, elimínala.

<ViewTransition> solo se activa con actualizaciones envueltas en una Transition de React, en revelaciones de Suspense o con useDeferredValue. Como en Next.js las navegaciones del router son transiciones, las animaciones se disparan automáticamente al cambiar de página.

Un detalle de ubicación importante: no coloques las animaciones de entrada y salida en layout.tsx, porque el layout persiste y esos eventos nunca se producen. El envoltorio va en cada page.tsx que deba participar:

// app/recetas/[slug]/page.tsx
import { ViewTransition } from "react";
import { notFound } from "next/navigation";
import { obtenerReceta, recetas } from "@/lib/recetas";
import { FavoriteButton } from "@/components/FavoriteButton";

export function generateStaticParams() {
  return recetas.map((r) => ({ slug: r.slug }));
}

export default async function RecetaPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const receta = obtenerReceta(slug);
  if (!receta) notFound();

  return (
    <ViewTransition enter="slide-up" exit="fade-out" default="none">
      <article className="mx-auto max-w-3xl p-8">
        <ViewTransition name={`titulo-${receta.slug}`}>
          <h1 className="text-4xl font-bold">{receta.titulo}</h1>
        </ViewTransition>
        <p className="mt-4 text-neutral-700">{receta.descripcion}</p>
        <FavoriteButton />
      </article>
    </ViewTransition>
  );
}

Las props enter, exit, update y share aceptan "auto" para el fundido por defecto del navegador, "none" para desactivarlo o el nombre de una clase que luego estilizas con los pseudo-elementos de la API:

/* app/globals.css */
::view-transition-new(.slide-up) {
  animation: 300ms ease-out both aparecer-arriba;
}

::view-transition-old(.fade-out) {
  animation: 200ms ease-in both desvanecer;
}

@keyframes aparecer-arriba {
  from {
    opacity: 0;
    translate: 0 16px;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

@keyframes desvanecer {
  to {
    opacity: 0;
  }
}

La prop name habilita las transiciones de elementos compartidos. Si en la tarjeta del listado envuelves el título con un <ViewTransition> que tenga el mismo nombre, por ejemplo titulo-ceviche-peruano, el navegador transformará el título de la tarjeta en el encabezado de la página de detalle, moviéndolo y escalándolo de forma continua. Los nombres deben ser únicos entre los elementos montados en cada momento.

Next.js 16.4 añade además la prop transitionTypes al componente Link y a los métodos push y replace del router. Con ella puedes etiquetar la dirección de la navegación, por ejemplo transitionTypes={['nav-forward']}, y en el <ViewTransition> de la página asignar una clase distinta según el tipo, pasando un objeto con claves por tipo y una clave default. Así consigues que avanzar deslice el contenido hacia un lado y retroceder hacia el contrario.

En cuanto a compatibilidad, la documentación de Next.js cita Chromium 125 o superior y versiones recientes de Safari y Firefox. Si el navegador no soporta la API, la aplicación funciona con normalidad y simplemente no hay animación, lo que la convierte en una mejora progresiva muy segura.

Cuándo usar Motion y cuándo View Transitions

Con dos herramientas disponibles, la pregunta natural es cuál elegir. No compiten tanto como parece; cada una brilla en un terreno distinto.

Motion es la mejor opción cuando:

  • Necesitas animaciones ligadas a gestos: hover, tap, arrastre o scroll.
  • Quieres física de resortes, interrupciones fluidas y animaciones que reaccionan al estado de React en tiempo real.
  • Animas componentes que aparecen y desaparecen dentro de una misma página, como modales, paneles o notificaciones.
  • Necesitas el mismo comportamiento en todos los navegadores, sin depender del soporte de una API concreta.

Las View Transitions encajan mejor cuando:

  • Animas cambios de ruta completos, incluyendo la salida de la página anterior, sin trucos con contextos internos.
  • Quieres transiciones de elementos compartidos entre páginas distintas.
  • Prefieres que el trabajo pesado lo haga el navegador con CSS, sin añadir código de animación al bundle de cada página.

En el Recetario, la combinación razonable es usar Motion para todo lo que ocurre dentro de la página (listado escalonado, tarjetas, favoritos, filtros) y <ViewTransition> para la navegación entre el listado y el detalle. Si eliges esta combinación, elimina el template.tsx animado para no duplicar efectos de entrada.

Accesibilidad y movimiento reducido

Las animaciones no son neutras para todo el mundo. Personas con trastornos vestibulares, migrañas o sensibilidad al movimiento pueden sentir mareo o malestar ante desplazamientos amplios, rotaciones o efectos de paralaje. Por eso los sistemas operativos ofrecen una opción para reducir el movimiento, que el navegador expone mediante la media query prefers-reduced-motion. Una aplicación profesional debe respetar las preferencias de movimiento del usuario.

Motion facilita esto de dos formas. La primera es global, con el componente MotionConfig:

// components/MotionProvider.tsx
"use client";

import { MotionConfig } from "motion/react";

export function MotionProvider({ children }: { children: React.ReactNode }) {
  return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}
// app/layout.tsx
import "./globals.css";
import { MotionProvider } from "@/components/MotionProvider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es">
      <body>
        <MotionProvider>{children}</MotionProvider>
      </body>
    </html>
  );
}

Con reducedMotion="user", cuando el sistema indica movimiento reducido, Motion desactiva las animaciones de transformación y de layout, pero mantiene las de opacidad y color. Es un equilibrio sensato: la interfaz sigue comunicando cambios de estado sin desplazar elementos por la pantalla.

La segunda forma es granular, con el hook useReducedMotion, que devuelve true cuando el usuario ha activado la preferencia:

"use client";

import { motion, useReducedMotion } from "motion/react";

export function HeroImagen() {
  const reducir = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, scale: reducir ? 1 : 1.1 }}
      animate={{ opacity: 1, scale: 1 }}
      transition={{ duration: reducir ? 0.2 : 0.8 }}
      className="h-64 rounded-3xl bg-orange-200"
    />
  );
}

Para las View Transitions, que se controlan desde CSS, aplica la media query directamente:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Otras recomendaciones de accesibilidad que conviene aplicar:

  • No transmitas información solo con movimiento. El botón de favoritos usa aria-pressed y una etiqueta descriptiva; el latido es un refuerzo visual, no la única señal.
  • Evita animaciones infinitas en zonas de lectura y ofrece una forma de pausarlas si son imprescindibles.
  • Mantén las duraciones cortas. Entre 150 y 400 milisegundos es suficiente para la mayoría de microinteracciones; más allá, la interfaz empieza a sentirse lenta.
  • Gestiona el foco tras una navegación o al abrir un panel, para que los usuarios de lector de pantalla y teclado no se pierdan.

Rendimiento: animar sin sacrificar la fluidez

Una animación que da tirones es peor que no tener animación. Para mantener 60 fotogramas por segundo, o más en pantallas de alta frecuencia, el principio clave es animar propiedades aceleradas por GPU siempre que sea posible.

Las propiedades transform (con sus atajos x, y, scale, rotate) y opacity pueden animarse en el compositor del navegador sin recalcular el layout ni repintar la página. Motion, además, aprovecha la Web Animations API para ejecutar estas animaciones con aceleración por hardware cuando es posible, de modo que siguen fluidas aunque el hilo principal esté ocupado con JavaScript.

En cambio, animar width, height, top, left, margin o padding obliga al navegador a recalcular la posición de los elementos en cada frame. Si necesitas que un elemento cambie de tamaño o posición dentro del flujo, usa la prop layout:

<motion.li layout transition={{ type: "spring", stiffness: 400, damping: 30 }}>
  {receta.titulo}
</motion.li>

Con layout, Motion mide el elemento antes y después del cambio y anima la diferencia usando transformaciones, una técnica muy eficiente para reordenar listas, filtrar resultados o expandir tarjetas.

Otras prácticas que marcan la diferencia:

  • Reduce el JavaScript enviado. Motion ofrece el componente m junto con LazyMotion para cargar solo las funcionalidades que usas, por ejemplo domAnimation en lugar de domMax si no necesitas arrastre ni animaciones de layout.
  • Mantén pequeños los componentes cliente. Cuanto más contenido se quede en componentes de servidor, menos código hidrata el navegador.
  • Usa whileInView junto con la opción once: true de la prop viewport para animar elementos al entrar en pantalla sin repetir el efecto en cada scroll.
  • Para valores que cambian continuamente, como un efecto ligado al scroll, usa useScroll, useMotionValue y useTransform. Los motion values se actualizan fuera del ciclo de render de React, así que no provocan re-renderizados en cada frame.
  • Mide con las herramientas de rendimiento del navegador antes de optimizar. La pestaña de rendimiento muestra si un frame se excede por layout, pintado o scripting.

Un ejemplo de animación al hacer scroll que no re-renderiza el componente:

"use client";

import { motion, useScroll, useTransform } from "motion/react";

export function BarraProgresoLectura() {
  const { scrollYProgress } = useScroll();
  const escala = useTransform(scrollYProgress, [0, 1], [0, 1]);

  return (
    <motion.div
      style={{ scaleX: escala, transformOrigin: "0% 50%" }}
      className="fixed left-0 top-0 h-1 w-full bg-orange-500"
    />
  );
}

Esta barra indica cuánto has avanzado leyendo una receta larga, y lo hace transformando la escala horizontal en lugar de cambiar el ancho, por lo que no provoca recálculos de layout.

Errores comunes y cómo resolverlos

Al integrar Motion en el App Router aparecen siempre los mismos problemas. Conocerlos te ahorrará tiempo de depuración.

El primero es olvidar la directiva de cliente. Si importas motion en un componente de servidor obtendrás un error que indica que estás usando funcionalidades de cliente en un componente de servidor, con un mensaje parecido a este:

Error: You're importing a component that needs createContext. This React Hook only works in a Client Component. To fix, mark the file (or its parent) with the "use client" directive.

La solución es añadir "use client" al inicio del archivo que usa Motion o mover la parte animada a un componente cliente independiente.

El segundo error es seguir importando desde framer-motion en un proyecto que ya instaló motion, o mezclar ambos paquetes. Esto puede duplicar la librería en el bundle y romper la comunicación entre componentes, por ejemplo cuando un AnimatePresence de un paquete envuelve un motion.div del otro. Desinstala framer-motion y busca todas las importaciones para cambiarlas a motion/react:

npm uninstall framer-motion
grep -rn "framer-motion" app components lib

El tercer problema son las animaciones de salida que nunca se ejecutan. Casi siempre se debe a una de estas causas: falta la key, la condición está fuera de AnimatePresence o el componente que contiene AnimatePresence se desmonta a la vez que su hijo. Revisa las tres reglas de la sección correspondiente.

El cuarto error típico tiene que ver con la hidratación. Si el valor de initial depende de algo que solo existe en el navegador, como window.innerWidth o una preferencia guardada en localStorage, el HTML generado en el servidor no coincidirá con el del cliente. Calcula esos valores dentro de un efecto o usa estados neutros en initial y ajusta después.

El quinto problema es esperar que el template anime la salida de la página. Como vimos, el template solo vuelve a montar la entrada. Si necesitas salida, recurre a <ViewTransition>.

El sexto error es mantener la bandera experimental.viewTransition en next.config tras actualizar a Next.js 16.4. La opción ya no existe y debes eliminarla. Del mismo modo, si <ViewTransition> no hace nada, comprueba que tienes React 19.3 o superior y que el cambio ocurre dentro de una transición: un setState normal no la activa.

Por último, cuidado con el exceso. Animar cada elemento de la interfaz produce una experiencia cansada y lenta. Una buena regla es que cada animación responda a una pregunta concreta del usuario: qué ha cambiado, de dónde viene este contenido o si su acción ha tenido efecto.

Buenas prácticas para producción

Para cerrar la parte práctica, estas son las recomendaciones que aplicaría en un proyecto real como el Recetario:

  • Centraliza las variants y las transiciones en un archivo compartido, por ejemplo lib/animaciones.ts. Así mantienes un lenguaje de movimiento coherente: las mismas duraciones, curvas y distancias en toda la aplicación.
  • Define tokens de movimiento igual que defines colores o espaciados: una duración corta para microinteracciones, una media para paneles y una larga para transiciones de página.
  • Envuelve la aplicación con MotionConfig reducedMotion="user" desde el primer día. Añadir accesibilidad al final siempre cuesta más.
  • Prueba en dispositivos de gama baja. Lo que se ve fluido en un portátil potente puede dar tirones en un móvil económico.
  • Combina con criterio: Motion para la interacción dentro de la página y View Transitions para los cambios de ruta, sin duplicar animaciones sobre el mismo elemento.
  • Revisa las notas de versión antes de actualizar. Tanto Motion como Next.js evolucionan rápido, y algunas APIs cambian de nombre o pasan de experimentales a estables entre versiones menores.

Conclusiones

Animar una aplicación Next.js moderna implica entender bien la frontera entre servidor y cliente. Motion, el sucesor del paquete framer-motion, se importa desde motion/react y vive en componentes cliente marcados con "use client", mientras que las páginas pueden seguir siendo componentes de servidor que obtienen los datos y delegan la parte visual.

A lo largo del tutorial construimos un catálogo de recetas con animaciones de entrada mediante initial y animate, listados escalonados con variants y stagger, tarjetas que responden al hover y al tap, un botón de favoritos con keyframes y un panel de filtros que entra y sale gracias a AnimatePresence. Para la navegación vimos tres caminos: template.tsx para entradas simples y robustas, AnimatePresence con usePathname y sus limitaciones reales en el App Router, y las View Transitions de React 19.3, que Next.js 16.4 integra con la navegación sin configuración adicional.

También repasamos los aspectos que separan una demo de una aplicación profesional: respetar prefers-reduced-motion con MotionConfig y useReducedMotion, animar transformaciones y opacidad en lugar de propiedades de layout, usar motion values para efectos continuos y evitar los errores más frecuentes, desde olvidar la directiva de cliente hasta mantener banderas de configuración que ya no existen.

El movimiento es una herramienta de comunicación. Úsalo para explicar cambios, confirmar acciones y guiar la atención, y la interfaz de tu aplicación Next.js ganará claridad además de atractivo visual.