Históricamente, implementar transiciones de pantalla fluidas (Page Transitions) y morphing cinemático de elementos en Single Page Applications (SPAs) implicaba una sobrecarga arquitectónica desmedida. Las librerías de animación en JavaScript debían mantener simultáneamente en memoria dos copias independientes del árbol DOM: la pantalla saliente y la nueva pantalla que se montaba. Esto duplicaba el consumo de memoria, forzaba hacks frágiles con position: absolute y exigía ciclos continuos de requestAnimationFrame en el Main Thread para interpolar coordenadas espaciales.
En arquitecturas con Server-Side Rendering (SSR) y React Server Components (RSC), este enfoque chocaba con un muro insalvable: un servidor no puede «renderizar dos páginas a la vez» exclusivamente para animar una transición.
La View Transitions API traslada la gestión de las transiciones desde las librerías JavaScript directamente al núcleo del navegador (Blink, WebKit, Gecko). El navegador toma una captura de pantalla del estado anterior, congela el repintado visual, aguarda la mutación del DOM en React 19, captura el nuevo estado y genera un árbol de pseudoelementos acelerado por hardware en el Top Layer para la interpolación en la GPU.
La View Transitions API libera por completo al Main Thread de JavaScript de la interpolación geométrica fotograma a fotograma. El navegador captura una imagen estática de la pantalla previa y la transforma suavemente en la nueva pantalla viva mediante la tarjeta gráfica. Esto supone un impacto de 0 KB en el bundle de JavaScript, elimina la duplicación de nodos DOM y proporciona soporte nativo de transiciones entre páginas tanto en SPAs como en aplicaciones multipágina (MPAs).
Conceptualmente, la View Transitions API actúa como un mezclador de vídeo cinematográfico de dos fotogramas integrado en el compositor del navegador:
┌─────────────────────────────────────────────────────────────┐
│ REACT 19 (Main Thread Execution) │
│ document.startViewTransition(() => { │
│ startTransition(() => setActiveScreen('checkout')); │
│ }); │
└──────────────────────────────┬──────────────────────────────┘
│
▼ 1. Captura de pantalla ANTES de mutar
┌─────────────────────────────────────────────────────────────┐
│ BROWSER TOP LAYER (GPU Compositor) │
│ │
│ ::view-transition │
│ └── ::view-transition-group(product-card) │
│ └── ::view-transition-image-pair(product-card) │
│ ├── ::view-transition-old(product-card) │
│ │ (Captura ráster estática ANTES) │
│ └── ::view-transition-new(product-card) │
│ (Nodo DOM vivo renderizado DESPUÉS) │
└─────────────────────────────────────────────────────────────┘
│
▼ 2. Fundido cruzado y FLIP en la GPU
┌─────────────────────────────────────────────────────────────┐
│ Resultado: 120 FPS, 0 KB de JS en bundle, cero Reflow │
└─────────────────────────────────────────────────────────────┘Al invocar document.startViewTransition(updateCallback), el navegador genera en la capa de servicio Top Layer una jerarquía aislada de pseudoelementos:
::view-transition: contenedor raíz que cubre todo el Viewport del navegador.::view-transition-group(name): contenedor geométrico de un grupo con nombre. El navegador anima automáticamente para él las propiedades transform, width y height.::view-transition-image-pair(name): contenedor de aislamiento (isolation: isolate) para el par de representaciones visuales.::view-transition-old(name): captura ráster estática del elemento ANTES de la mutación del DOM.::view-transition-new(name): nodo DOM renderizado activo DESPUÉS de la mutación del DOM.A los elementos cuya posición deba interpolarse de forma individual se les asigna una propiedad CSS única:
view-transition-name: product-hero-42;.
El código invoca document.startViewTransition(callback). El navegador captura una instantánea ráster del viewport actual.
Se congela el dibujado. React 19 ejecuta la reconciliación dentro del callback y aplica el nuevo DOM de forma síncrona.
El navegador examina el nuevo árbol DOM, mide las coordenadas finales y genera ::view-transition-new.
El navegador construye la jerarquía de pseudoelementos en el Top Layer y ejecuta el fundido cruzado y la interpolación FLIP a 120 FPS.
¿Qué pseudoelemento dentro de la jerarquía ::view-transition generada por el navegador es el responsable de interpolar las dimensiones geométricas (width, height) y las coordenadas espaciales (transform) entre la posición antigua y la nueva?
Al alternar entre modo claro y oscuro, la pantalla no parpadea. Un círculo de revelado radial se expande desde el punto exacto donde el usuario pulsó el botón del cursor mediante el estilizado de ::view-transition-new(root).
El usuario pasa de la vista detallada de una película al catálogo. El reproductor de vídeo se reduce suavemente hacia la esquina inferior derecha sin interrumpir la decodificación de vídeo gracias a view-transition-name: media-player.
Al pulsar un filtro de categorías, las tarjetas de producto reorganizan sus posiciones con suavidad, el encabezado se interpola y el buscador permanece anclado sin desmontajes innecesarios del DOM.
La navegación entre las fases de envío y pago desplaza lateralmente los paneles del formulario mientras la columna lateral del resumen del pedido permanece totalmente estática.
La principal dificultad al combinar React con startViewTransition es que el navegador exige que el callback síncrono aplique la mutación al DOM de inmediato. Si React difiere el renderizado a un carril concurrente (Lanes) asíncrono, el navegador tomará la segunda captura antes de que el DOM cambie, abortando la animación.
En React 19, envolver la actualización del estado dentro de startTransition dentro del callback resuelve esta sincronización:
Por defecto, los navegadores aplican un fundido cruzado básico (opacity: 1 -> 0 y 0 -> 1). Para personalizar los efectos, el desarrollador sobreescribe reglas CSS:
/* Revelado circular para cambio de tema */
::view-transition-old(root) {
animation: none;
z-index: 1;
}
::view-transition-new(root) {
animation: circular-reveal 0.45s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 9999;
}
@keyframes circular-reveal {
from {
clip-path: circle(0% at var(--click-x, 50%) var(--click-y, 50%));
}
to {
clip-path: circle(150% at var(--click-x, 50%) var(--click-y, 50%));
}
}
/* Regla obligatoria de accesibilidad: desactivar animaciones para usuarios sensibles */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Un desarrollador aplica la clase CSS .card { view-transition-name: product-card; } a las 20 tarjetas de un catálogo. El usuario pulsa un botón de filtro. ¿Qué ocurre al ejecutarse document.startViewTransition()?
Construyamos un subsistema completo de producción para React 19:
src/hooks/useViewTransitionState.ts: Hook de gestión de estado reactivo que combina document.startViewTransition con React.startTransition y provee degradación segura en navegadores antiguos.src/components/theme/ThemeCircularRevealToggle.tsx: Interruptor de tema cromático con cálculo geométrico para el revelado circular radial centrado en el cursor.src/components/gallery/ProductCatalogMorph.tsx: Catálogo de productos que transforma miniaturas en vistas detalladas mediante view-transition-name dinámicos.'use client';
import * as React from 'react';
/**
* Valida el soporte de View Transitions API en el entorno actual
*/
export function isViewTransitionSupported(): boolean {
return typeof document !== 'undefined' && 'startViewTransition' in document;
}
/**
* Hook de estado reactivo con animación nativa de transición del navegador
*/
export function useViewTransitionState<T>(initialState: T) {
const [state, setState] = React.useState<T>(initialState);
const setViewTransitionState = React.useCallback(
(nextState: T | ((prev: T) => T)) => {
// Degradación progresiva para navegadores sin soporte
if (!isViewTransitionSupported()) {
React.startTransition(() => {
setState(nextState);
});
return;
}
// Ejecución de la transición nativa del navegador
document.startViewTransition(() => {
React.startTransition(() => {
setState(nextState);
});
});
},
[]
);
return [state, setViewTransitionState] as const;
}'use client';
import * as React from 'react';
import { isViewTransitionSupported } from '@/hooks/useViewTransitionState';
export function ThemeCircularRevealToggle() {
const [isDark, setIsDark] = React.useState(true);
const toggleTheme = (event: React.MouseEvent<HTMLButtonElement>) => {
const nextDark = !isDark;
if (!isViewTransitionSupported()) {
setIsDark(nextDark);
document.documentElement.classList.toggle('dark', nextDark);
return;
}
// Calcula las coordenadas del centro del botón para anclar la animación
const rect = event.currentTarget.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
document.documentElement.style.setProperty('--click-x', `${x}px`);
document.documentElement.style.setProperty('--click-y', `${y}px`);
const transition = document.startViewTransition(() => {
setIsDark(nextDark);
document.documentElement.classList.toggle('dark', nextDark);
});
transition.ready.catch((err: unknown) => {
console.warn('[View Transition]: Interrumpida por el usuario', err);
});
};
return (
<button
type="button"
onClick={toggleTheme}
className="px-4 py-2 rounded-xl bg-bg-surface border border-border text-xs font-mono font-bold text-text-primary hover:border-accent-violet transition-colors cursor-pointer shadow-sm"
>
Tema: {isDark ? '🌙 OSCURO' : '☀️ CLARO'}
</button>
);
}'use client';
import * as React from 'react';
import { useViewTransitionState } from '@/hooks/useViewTransitionState';
export interface ProductItem {
readonly id: string;
readonly name: string;
readonly price: number;
readonly category: string;
readonly description: string;
readonly color: string;
}
const PRODUCTS: readonly ProductItem[] = [
{
id: 'prod-1',
name: 'Teclado Mecánico Custom 75%',
price: 190,
category: 'Periféricos',
description: 'Chasis de aluminio CNC, interruptores Gateron Oil King lubricados de fábrica, hot-swap y amortiguación Poron.',
color: 'bg-violet-600',
},
{
id: 'prod-2',
name: 'Ratón Inalámbrico Ultralight',
price: 130,
category: 'Periféricos',
description: 'Sensor óptico de 26 000 DPI, microinterruptores ópticos, peso ligero de 52 gramos y tasa de sondeo de 4000 Hz.',
color: 'bg-cyan-600',
},
];
export function ProductCatalogMorph() {
const [selectedId, setSelectedId] = useViewTransitionState<string | null>(null);
const selectedProduct = PRODUCTS.find((p) => p.id === selectedId);
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
{!selectedProduct ? (
/* Cuadrícula de productos del catálogo */
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{PRODUCTS.map((product) => (
<div
key={product.id}
onClick={() => setSelectedId(product.id)}
className="p-6 rounded-2xl bg-bg-surface border border-border cursor-pointer hover:border-accent-violet/50 transition-colors shadow-md space-y-4"
>
<div
style={{ viewTransitionName: `badge-${product.id}` }}
className={`w-12 h-12 rounded-xl ${product.color} flex items-center justify-center font-mono font-bold text-white text-sm shadow-inner`}
>
#{product.id.slice(-1)}
</div>
<div>
<span className="text-[10px] font-mono uppercase tracking-wider text-accent-cyan font-bold block mb-1">
{product.category}
</span>
<h3
style={{ viewTransitionName: `title-${product.id}` }}
className="text-lg font-bold text-text-primary leading-snug"
>
{product.name}
</h3>
</div>
<div
style={{ viewTransitionName: `price-${product.id}` }}
className="text-base font-mono font-bold text-text-primary"
>
${product.price}
</div>
</div>
))}
</div>
) : (
/* Vista detallada del producto expandido */
<div className="p-8 rounded-3xl bg-bg-surface border border-accent-violet/40 shadow-2xl space-y-6">
<div className="flex items-start justify-between">
<div
style={{ viewTransitionName: `badge-${selectedProduct.id}` }}
className={`w-20 h-20 rounded-2xl ${selectedProduct.color} flex items-center justify-center font-mono font-extrabold text-white text-2xl shadow-xl`}
>
#{selectedProduct.id.slice(-1)}
</div>
<button
type="button"
onClick={() => setSelectedId(null)}
className="px-4 py-2 text-xs font-mono font-semibold rounded-xl bg-bg-elevated border border-border text-text-muted hover:text-white transition-colors cursor-pointer"
>
← Volver al catálogo
</button>
</div>
<div>
<span className="text-xs font-mono uppercase tracking-wider text-accent-cyan font-bold block mb-1">
{selectedProduct.category}
</span>
<h1
style={{ viewTransitionName: `title-${selectedProduct.id}` }}
className="text-2xl sm:text-3xl font-black text-text-primary"
>
{selectedProduct.name}
</h1>
<div
style={{ viewTransitionName: `price-${selectedProduct.id}` }}
className="text-2xl font-mono font-bold text-accent-violet mt-2"
>
${selectedProduct.price}
</div>
</div>
<p className="text-sm text-text-secondary leading-relaxed pt-4 border-t border-border">
{selectedProduct.description}
</p>
</div>
)}
</div>
);
}Declarar .card { view-transition-name: product-card; } en CSS provoca que múltiples nodos compartan el mismo token de transición. El navegador no puede resolver los pares de imágenes y aborta arrojando un error InvalidStateError.
Solución: Asigna view-transition-name dinámicamente mediante estilos en línea con un ID único: style={{ viewTransitionName: 'card-' + item.id }}.
Ejecutar peticiones de red prolongadas con await fetch(...) dentro del callback bloquea la pantalla en una instantánea congelada durante segundos.
Solución: Resuelve las peticiones de red ANTES de llamar a startViewTransition. El callback debe limitarse a mutaciones síncronas del estado de React.
Cuando un elemento pasa de ser cuadrado (1:1) a panorámico (16:9), el navegador estira la textura ::view-transition-old, provocando distorsión tipográfica y desenfoque.
Solución: Aplica reglas explícitas de preservación: object-fit: cover; width: 100%; height: 100%;.
Reproducir transiciones de pantalla a gran escala para usuarios con trastornos vestibulares vulnera las pautas WCAG 2.2.
Solución: Incluye siempre: @media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }.
::view-transition-group (que gestiona la geometría transform/width/height), la captura estática ::view-transition-old y el nodo vivo entrante ::view-transition-new.view-transition-name debe ser estrictamente único en todo el documento para impedir cancelaciones por InvalidStateError.document.startViewTransition con React.startTransition, asegurando que las peticiones de datos terminen antes de iniciar la animación.@media (prefers-reduced-motion: reduce) es de obligado cumplimiento para respetar los estándares de accesibilidad (a11y).