Development13 min read0 visualizaciones

    React 19.3 y View Transitions en Next.js 16.3

    View Transitions se estabilizó en React 19.3, no en 19.2, y Next.js deja de exigir el flag experimental. Morph, Suspense y los fallos que nadie documenta.

    Roberto Serrano

    Roberto Serrano

    Ingeniero de Software

    ¿Te resulta útil? Déjame saber con un like o guárdalo para luego.

    La versión que casi todo el mundo cita mal

    Hay una frase que circula en tutoriales, hilos y vídeos: «React 19.2 trae View Transitions». Es falsa, y no es un matiz menor. React 19.2 se publicó el 1 de octubre de 2025 y trajo Activity, useEffectEvent, cacheSignal, Performance Tracks y Partial Pre-rendering. View Transitions no estaba ahí.

    La feature se estabilizó en React 19.3, el 9 de septiembre de 2026, junto con Fragment Refs, browser() y Trusted Types. Once meses de diferencia, y un cambio de número menor que mucha documentación interna sigue citando mal. Si tu artículo dice 19.2, cualquiera que abra el blog de React lo va a notar en treinta segundos.

    La confusión tiene una explicación razonable, y merece la pena contarla porque explica mucho de cómo evolucionan estas APIs: React 19.2 preparó el terreno. Cambió el prefijo por defecto de useId de :r: a _r_ precisamente para que los identificadores generados fueran válidos como view-transition-name, y añadió el batching de Suspense en SSR para alinear los reveals del servidor con los del cliente. Es decir, hizo los deberes estructurales para que la animación funcionara después. Pero no entregó la API.

    El que la entregó fue el 19.3. Y en Next.js ya no hay flag que activar: la integración llega sin configuración, y la 16.4 —del 6 de octubre de 2026— es la primera que trae React 19.3 de serie.

    Lo que trajo cada versión

    Para que no haya dudas, el reparto es este.

    VersiónFechaLo relevante
    React 19.21-oct-2025Activity, useEffectEvent, cacheSignal, Performance Tracks, Partial Pre-rendering, useId con _r_
    React 19.39-sep-2026ViewTransition y addTransitionType estables, Fragment Refs, browser(), Trusted Types

    Activity merece una línea porque es más potente de lo que parece: en modo hidden oculta el subárbol, desmonta los efectos y difiere las actualizaciones hasta que React no tiene trabajo urgente, pero conserva el estado. Es la pieza que hace viable precargar una pantalla sin pagar sus efectos mientras no se ve.

    Y useEffectEvent resuelve otro clásico: separar la parte «evento» de un Effect. No va en el array de dependencias, solo se declara en el mismo componente o hook que su Effect, y necesita eslint-plugin-react-hooks actualizado. Si tu lint no lo conoce, te lo marcará como error.

    En Next.js 16.3 no hay nada que activar

    Aquí está el segundo mito, y es más caro porque hace perder tardes enteras. Durante la era canary, con Next 15 y hasta la 16.2, había que poner esto:

    // NO hacer esto en 16.3
    experimental: {
    	viewTransition: true
    }
    

    Ese flag habilitaba la integración cuando React todavía era canary y el componente se importaba como unstable_ViewTransition. En 16.3 no hace falta, y activarlo es ruido como mínimo.

    La guía oficial lo dice sin ambigüedad: las view transitions funcionan en el App Router sin configuración, porque React 19.3 ya incluye el componente y la API. Ojo con la versión de React: la 16.3 no trae la 19.3 de serie —eso llega con la 16.4—, así que comprueba tu package.json antes de dar por hecho que el import existe. El único import que necesitas:

    import { ViewTransition } from 'react'
    

    Y una consecuencia que sorprende a quien viene de animar a mano: las navegaciones de ruta en Next.js son Transitions. Eso significa que <ViewTransition> se dispara solo al navegar, sin startTransition manual. Un setState normal, en cambio, es un update urgente y no anima. Esa asimetría explica buena parte de los «no me funciona» que vas a leer por ahí.

    Para las transiciones con dirección hay una pieza más: transitionTypes en <Link>, disponible desde Next.js 16.2.0.

    <Link href={`/photo/${photo.id}`} transitionTypes={['nav-forward']}>
    

    Funciona en Server Components, sin wrapper. Si dudas de si existe en tu versión, compruébalo en tu propio node_modules: un grep sobre node_modules/next/dist/ te saca de la duda en un segundo.

    La API real: cuatro disparadores y una regla dura

    ViewTransition no anima «cosas». Anima cuatro situaciones concretas, y confundirlas es el origen de la mitad de los bugs.

    DisparadorCuándo se activa
    enterEl componente se inserta y es el primer nodo de su subárbol
    exitSe elimina y era el primero de su subárbol
    updateHay una mutación DOM dentro, o cambia el tamaño o la posición por un hermano
    shareUn name desaparece en un sitio y reaparece en otro

    La regla dura está en las dos primeras filas: solo anima enter y exit si el componente está antes de cualquier nodo DOM. Si metes un div por encima, deja de animar en silencio. Sin error, sin warning. Nada. Es el fallo más desmoralizante de toda la API.

    Las props de clase (enter, exit, update, share, default) aceptan "auto", "none", un nombre de clase o un objeto indexado por transition type:

    <ViewTransition
    	default="none"
    	enter={{ 'nav-forward': 'slide-in', 'nav-back': 'slide-in-reverse', default: 'auto' }}
    	exit="auto"
    	update="cross-fade"
    />
    

    Y ojo con default="none": apaga todos los disparadores salvo los que listes explícitamente. No es un valor por defecto inocente, es un interruptor general.

    Para lo que no se puede hacer con CSS hay eventos JS: onEnter, onExit, onUpdate y onShare. Reciben (instance, types) y deben devolver una función de cleanup:

    <ViewTransition
      onEnter={(instance, types) => {
        const anim = instance.new.animate(
          [{ opacity: 0 }, { opacity: 1 }],
          { duration: 500 }
        );
        return () => anim.cancel();
      }}
    >
    

    instance expone old, new, name, group e imagePair. Solo se dispara un evento por boundary y transición, y onShare gana sobre enter y exit.

    Un detalle poco conocido: React añade cada transition type al elemento como view transition type del navegador, así que puedes filtrar animaciones sin tocar las props:

    :active-view-transition-type(nav-forward) ::view-transition-old(root) {
    	animation-name: slide-out-left;
    }
    

    Morph de thumbnail a hero

    El caso que todo el mundo quiere: la miniatura del listado se convierte en la imagen grande de la ficha. Es un share, y necesita el mismo name en los dos lados.

    // components/photo-grid.tsx
    import { ViewTransition } from 'react'
    import Image from 'next/image'
    import Link from 'next/link'
    
    export function PhotoGrid({ photos }) {
    	return (
    		<div className="grid grid-cols-3 gap-3">
    			{photos.map(photo => (
    				<Link key={photo.id} href={`/photo/${photo.id}`} transitionTypes={['nav-forward']}>
    					<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
    						<Image src={photo.src} alt={photo.title} width={400} height={267} />
    					</ViewTransition>
    				</Link>
    			))}
    		</div>
    	)
    }
    
    // app/photo/[id]/photo-content.tsx
    <ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
    	<div style={{ position: 'relative', aspectRatio: '3 / 2' }}>
    		<Image src={photo.src} alt={photo.title} fill />
    	</div>
    </ViewTransition>
    
    /* app/globals.css */
    ::view-transition-group(.morph) {
    	animation-duration: 400ms;
    }
    ::view-transition-image-pair(.morph) {
    	animation-name: via-blur;
    }
    @keyframes via-blur {
    	30% {
    		filter: blur(3px);
    	}
    }
    

    Dos avisos sobre este bloque. Primero: con default="none" hay que mantener share="morph" explícito; sin él, la pareja deja de morphar en silencio. Segundo, y es la parte que casi nadie explica: el morph depende del prefetch.

    El shared element solo se reproduce si el contenido de destino renderiza en el mismo commit que la navegación. Si la página destino suspende y entra un fallback, no hay pareja que emparejar y lo que ves es la animación de enter. Por eso la feature de animación y la de navegación instantánea son la misma historia: sin Partial Prefetching o sin página cacheada, el morph es una lotería.

    Suspense: dos patrones que no se mezclan

    Aquí hay una discrepancia documentada que conviene conocer antes de copiar código de dos sitios distintos. Existen dos formas de animar un Suspense, y producen resultados diferentes.

    La de React es la simple: el ViewTransition envuelve el Suspense.

    <ViewTransition>
    	<Suspense fallback={<Loading />}>
    		<Component />
    	</Suspense>
    </ViewTransition>
    

    React dispara una animación de tipo update del fallback al contenido final. Es un solo crossfade: no puedes dar direcciones distintas a la salida del skeleton y a la entrada del contenido.

    La de Next.js va más fino, con dos boundaries y clases distintas:

    <Suspense
    	fallback={
    		<ViewTransition exit="slide-down" default="none">
    			<PhotoContentSkeleton />
    		</ViewTransition>
    	}
    >
    	<ViewTransition enter="slide-up" default="none">
    		<PhotoContent id={id} />
    	</ViewTransition>
    </Suspense>
    
    :root {
    	--duration-exit: 150ms;
    	--duration-enter: 210ms;
    	--duration-move: 400ms;
    }
    
    ::view-transition-old(.slide-down) {
    	animation:
    		var(--duration-exit) ease-out both fade reverse,
    		var(--duration-exit) ease-out both slide-y reverse;
    }
    ::view-transition-new(.slide-up) {
    	animation:
    		var(--duration-enter) ease-in var(--duration-exit) both fade,
    		var(--duration-move) ease-in both slide-y;
    }
    @keyframes fade {
    	from {
    		filter: blur(3px);
    		opacity: 0;
    	}
    	to {
    		filter: blur(0);
    		opacity: 1;
    	}
    }
    @keyframes slide-y {
    	from {
    		transform: translateY(10px);
    	}
    	to {
    		transform: translateY(0);
    	}
    }
    

    Elige uno y no los mezcles. Si combinas el patrón de React con el de Next acabas con dos transiciones compitiendo por el mismo cambio de DOM, y el resultado no es «más animación»: es una animación incorrecta que además es difícil de depurar porque las dos son válidas por separado.

    Con transitionTypes puedes dar dirección a la navegación. La clave es envolver el contenido de la página, no el layout:

    // app/photo/[id]/page.tsx
    <ViewTransition
    	enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
    	exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}
    	default="none"
    >
    	{/* contenido */}
    </ViewTransition>
    
    <Link href="/" transitionTypes={['nav-back']}>
    	← Gallery
    </Link>
    

    Y para que el header no se mueva mientras el resto de la página transiciona, se le da nombre propio y se le quita la animación:

    <header style={{ viewTransitionName: 'site-header' }}>{/* nav */}</header>
    
    ::view-transition-group(site-header) {
    	animation: none;
    	z-index: 100;
    }
    ::view-transition-old(site-header) {
    	display: none;
    }
    ::view-transition-new(site-header) {
    	animation: none;
    }
    

    Ese z-index no es decorativo: las capas de view transition se dibujan por encima del contenido normal, y sin él el header puede quedar tapado por su propia instantánea.

    Los siete fallos que vas a cometer

    Casi todos los problemas de esta API son de configuración, no de CSS.

    FalloSíntomaArreglo
    Un div antes del ViewTransitionenter y exit nunca disparanDejarlo como primer nodo del subárbol
    ViewTransition en el layout envolviendo childrenLa animación de página no funcionaMoverlo a cada page.tsx
    name duplicadoAnimación ausente o erráticaNombres únicos, derivados del id
    default="none" sin shareLa pareja deja de morpharAñadir share="morph"
    setState sin startTransitionNo animaEnvolver en startTransition
    flushSync durante la transiciónTransición saltadaSacarlo del camino crítico
    Probar la dirección en next devNo se ve el comportamiento realBuild de producción con caché de cliente frío

    Dos apuntes sobre la tabla. El name debe ser único en toda la app en cada momento; dos elementos con el mismo nombre rompen la transición de formas que no siempre son evidentes. Y next dev no hace prefetch automático de <Link>, así que la navegación direccional se ve distinta ahí que en producción: es un fallo de método de prueba, no del código. Otro detalle que sorprende: el botón «atrás» del navegador no lleva transition type, así que no hay slide direccional; el morph sí sigue funcionando porque depende del name, no del tipo.

    Accesibilidad: dos bloques de CSS que separan una demo de un producto

    Esto es lo que casi ningún artículo de View Transitions cuenta, y es lo que decide si la feature es aceptable o no.

    Primero: React no respeta prefers-reduced-motion automáticamente. Lo dice de forma explícita en la documentación. Si no lo escribes tú, no existe:

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

    La versión refinada conserva los crossfades de opacidad y elimina solo el movimiento posicional. Los slides direccionales son el mayor riesgo para quien tiene sensibilidad al movimiento; un morph o un fade, mucho menos. Anular todo es lo seguro, pero no siempre es lo mejor.

    Segundo: el overlay ::view-transition captura los pointer events. Durante la animación, los clics se pierden. La solución es una línea:

    ::view-transition {
    	pointer-events: none;
    }
    

    Aun así queda un matiz incómodo: el hit-testing sigue saltándose los participantes con name, como el header anclado. Traducción práctica: no nombres elementos que el usuario pulsa rápido, y mantén las transiciones cortas. Un header con name y una animación de 600 ms es una receta para que los clics en el menú no respondan en la primera pulsación.

    Compatibilidad y cómo se degrada

    La integración de React usa transition types y view-transition-class, lo que en la práctica significa Chromium 125 o superior y versiones recientes de Safari y Firefox. La propia guía de Next.js reconoce que «algunas animaciones pueden comportarse de forma distinta en Safari», así que conviene probar los casos concretos en lugar de asumir paridad.

    La buena noticia es cómo falla: sin soporte, la app funciona normal, simplemente no anima. No hay polyfill que cargar, no hay pantalla en blanco, no hay error. Es una mejora progresiva de manual, y eso es exactamente lo que quieres de una feature de presentación.

    Dos límites que sí conviene tener claros. El primero: React interrumpe cualquier View Transition que no haya lanzado él. Si tienes un document.startViewTransition manual o una librería que lo use, hay que migrar; no coexisten. El segundo: esto es same-document. Las view transitions cross-document de MPA son otra historia y no vienen de React.

    Cierre: decidir cuándo no animar

    Next.js 16.3 ya no te pide activar View Transitions. Te pide decidir cuándo no animarlas. Y esa es una decisión de diseño de información, no de estética: la dirección comunica jerarquía (entrar en un detalle, volver atrás), el crossfade comunica continuidad (mismo sitio, otro contenido), y el morph comunica identidad (este objeto es el mismo que aquel).

    Cuando todo anima, nada significa nada. Por eso default="none" no es una optimización, es la configuración por defecto que deberías escribir a mano en cada página. Y por eso los dos bloques de CSS de accesibilidad no son un extra: son la diferencia entre una demo que se ve bien en un portátil y algo que se puede publicar.

    Si vas a probarlo en tu sitio, empieza por un solo caso: el listado a la ficha, con morph y header anclado. Mide en un build de producción, con caché fría. Y añade prefers-reduced-motion antes de enseñárselo a nadie.

    Artículos relacionados

    Artículo escrito por Roberto Serrano — Desarrollador web, automatizaciones e IA en Tarragona, España.

    ¿Te ha gustado? Compártelo.

    Más sobre el tema