Animar la revelación de tensión con ViewTransition: soluciones alternativas, imágenes, fuentes
Aprenda cómo el uso de Suspense dentro de ViewTransition anima el cambio del esqueleto por el contenido, qué propiedades evitan animaciones redundantes y cómo React espera a que se carguen las imágenes y las fuentes.
Fuentes: React 19.3 View Transitions with Suspense: Animate the Fallback, Wait for Images and Fonts
Un límite de suspense ya describe una transición: muestra un contenido alternativo y luego sustituye por el contenido real. Históricamente esa sustitución era un corte brusco, a menudo seguido por la aparición de una imagen unos instantes después y por la carga de una fuente web posteriormente. React 19.3 permite envolver ese límite en <ViewTransition> para que la sustitución se convierta en una animación; además, y de forma menos evidente, hace que React retrase dicha animación hasta que las imágenes y fuentes web del contenido entrante hayan cargado. Esta guía muestra cómo configurarlo, cómo evitar que la animación se ejecute cuando no debería, dónde colocar los límites y qué función realizan las esperas integradas.
Dado que este comportamiento se lanzó recientemente, en el momento de escribir esto, confirme los detalles consultando la documentación actual de React antes de confiar en tiempos exactos en entornos de producción. Los ejemplos suponen que ya sabe que <ViewTransition> se anima con desencadenantes específicos (enter, exit, update) y que debe envolver al DOM que anima.
Conviirtiendo la revelación en una animación
La versión mínima simplemente coloca un <ViewTransition> alrededor del límite de Suspense:
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
No es necesario utilizar startTransition para el intercambio, ya que React considera automáticamente la visualización de un límite Suspense como una transición. Una vez que Forecast termina de suspender el contenido, la acción que reemplaza al esqueleto se ejecuta dentro de una transición de vista. Dado que el esqueleto y la previsión se encuentran dentro del mismo <ViewTransition>, comparten el mismo nombre de instantánea generada, por lo que el cambio se trata como una actualización y el navegador hace un fundido entre uno y otro.
Ejemplo de previsión ejecutable
La demostración comienza con las importaciones necesarias:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
El código del componente almacena una Promise por ciudad en un Map, ya que use() necesita recibir la misma instancia de Promise en cada renderizado; crear una nueva en cada renderizado suspendería el proceso indefinidamente. Forecast lee los datos ya resueltos con use(), Skeleton muestra una tarjeta de marcador temporal y App activa o desactiva todo el contenedor dentro de startTransition:
// use() needs the same Promise instance across renders, so cache it
const cache = new Map();
function fetchForecast(city) {
if (!cache.has(city)) {
cache.set(city, new Promise(resolve =>
setTimeout(() => resolve({ city, high: 31, low: 24, sky: '⛅' }), 1100)
));
}
return cache.get(city);
}function Forecast({ city }) {
const f = use(fetchForecast(city));
return <div className="card">{f.sky} {f.city}: {f.high}° / {f.low}°</div>;
}function Skeleton() {
return <div className="card skeleton">Loading forecast…</div>;
}export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => startTransition(() => setShow(s => !s))}>
{show ? 'Hide' : 'Load'} forecast
</button>
{show && (
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Forecast city="Pune" />
</Suspense>
</ViewTransition>
)}
</>
);
}
Varias reglas CSS dan estilo a la tarjeta y asignan un fondo neutro al marcador temporal, utilizando light-dark() para ambos esquemas de colores:
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
Una regla final ralentiza el efecto de desvanecimiento por defecto para que el cambio sea fácil de observar mientras se realizan pruebas:
/* slow the default cross-fade so the swap is visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Al hacer clic en el botón se muestra el esqueleto, se espera aproximadamente un segundo a que se realice la solicitud simulada y luego el pronóstico aparece de forma gradual sobre el esqueleto. Sin embargo, si lo ocultas y vuelves a cargarlo, notarás un problema: los datos ahora están en caché, por lo que no aparece ninguna alternativa, pero la tarjeta sigue apareciendo gradualmente, tal como lo hacía cuando estaba oculta. Esto nos lleva a la siguiente sección.
Detener animaciones que no solicitaste
Un <ViewTransition> sin propiedades anima cada evento desencadenante: enter al montar, exit al desmontar y update cada vez que cambia el contenido interno. Al usarlo con Suspense, se generan hasta tres animaciones cuando solo se pretendía una. La documentación de React describe con claridad el comportamiento deseado para la carga de la interfaz de usuario:
- La opción de respaldo debe aparecer inmediatamente y sin animación, ya que el usuario acaba de realizar una acción y espera una respuesta instantánea.
- El cambio de la opción de respaldo al contenido debe hacerse con animación.
- El contenido que ya está en caché y, por lo tanto, nunca se suspende, debe mostrarse de inmediato y sin animación.
Dos propiedades expresan exactamente eso. default="none" desactiva todos los disparadores, mientras que update="auto" vuelve a activar el que te interesa:
<ViewTransition update="auto" default="none">
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
Comparación de las dos configuraciones
La segunda demostración utiliza las mismas importaciones:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
Permite cargar una de las tres ciudades y alternar entre la versión básica <ViewTransition> y la versión con dos propiedades. Observe key={city} en <ViewTransition>: al cambiar de ciudad se crea un nuevo límite en lugar de actualizar el existente.
const cache = new Map();
function fetchForecast(city) {
if (!cache.has(city)) {
const data = { Pune: '⛅ 31° / 24°', Goa: '🌧 29° / 26°', Shimla: '☀️ 18° / 9°' }[city];
cache.set(city, new Promise(r => setTimeout(() => r(data), 1100)));
}
return cache.get(city);
}function Forecast({ city }) {
const text = use(fetchForecast(city));
return <div className="card">{city}: {text}</div>;
}export default function App() {
const [city, setCity] = useState(null);
const [onlyUpdates, setOnlyUpdates] = useState(false);
const props = onlyUpdates ? { update: 'auto', default: 'none' } : {}; return (
<>
<div className="bar">
{['Pune', 'Goa', 'Shimla'].map(c => (
<button key={c} onClick={() => startTransition(() => setCity(city === c ? null : c))}>
{city === c ? 'Hide' : 'Load'} {c}
</button>
))}
<label><input type="checkbox" checked={onlyUpdates} onChange={e => setOnlyUpdates(e.target.checked)} /> only animate updates</label>
</div>
{city && (
<ViewTransition key={city} {...props}>
<Suspense fallback={<div className="card skeleton">Loading {city}…</div>}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
)}
</>
);
}
La hoja de estilo agrega una barra de herramientas flexible y las mismas reglas para tarjetas, esqueletos y fundidos cruzados con efecto de desaceleración:
.bar { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Con la opción “solo animar actualizaciones” activada, una ciudad que aún no se ha cargado muestra inmediatamente su estructura básica y luego se anima para mostrar su contenido, mientras que una ciudad ya cargada simplemente aparece sin animación. La documentación recomienda usar la animación al mostrar contenido en estado de espera solo ocasionalmente, y en absoluto para el contenido almacenado en caché que podría haberse renderizado de inmediato, ya que la animación solo añade latencia. En la práctica, update="auto" default="none" debe utilizarse en casi todos los <ViewTransition> que rodean un límite de estado de espera.
La ubicación determina qué disparadores se activan
El lugar donde se coloca el <ViewTransition> en relación con el estado de espera controla qué disparadores se ejecutan, y por lo tanto qué partes se pueden animar por separado.
Afuera del límite: una sola actualización
Al envolver el elemento de suspense desde el exterior se crea un único límite que considera el cambio como una actualización:
<ViewTransition update="cross">
<Suspense fallback={<A />}>
<B />
</Suspense>
</ViewTransition>
El recurso de respaldo y el contenido comparten el mismo nombre de captura, por lo que lo único que se puede hacer es realizar una transición gradual entre ellos, en este caso con una clase personalizada cross.
Dentro de ambas ramas: salida e entrada
Al envolver por separado el recurso de respaldo y el contenido se crean dos límites independientes:
<Suspense
fallback={<ViewTransition exit="out"><A /></ViewTransition>}
>
<ViewTransition enter="in"><B /></ViewTransition>
</Suspense>
El recurso de respaldo se retira con una animación exit y el contenido llega con una animación enter, lo que permite que el marcador de posición desaparezca hacia abajo mientras el contenido real asume su lugar.
Una tarjeta de perfil que utiliza la colocación interna
La tercera demostración vuelve a comenzar con las importaciones:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
loadProfile crea una nueva Promise en cada clic, por lo que el boundary se suspende cada vez. Profile y Skeleton envuelven su marcado respectivamente en su propio <ViewTransition>, utilizando enter="rise" y exit="sink" respectivamente, con default="none". El valor key={attempt} en Suspense es importante: crea un nuevo boundary para cada carga, ya que un boundary ya montado que se suspende nuevamente dentro de una Transición sigue mostrando el contenido anterior en lugar de mostrar el contenido alternativo.
function loadProfile() {
// a fresh Promise per click, so it suspends every time
return new Promise(r => setTimeout(() => r({ name: 'Rahul', role: 'Frontend' }), 1100));
}function Profile({ promise }) {
const p = use(promise);
return (
<ViewTransition enter="rise" default="none">
<div className="card">👤 {p.name} — {p.role}</div>
</ViewTransition>
);
}function Skeleton() {
return (
<ViewTransition exit="sink" default="none">
<div className="card skeleton">loading…</div>
</ViewTransition>
);
}export default function App() {
const [attempt, setAttempt] = useState(0);
const [promise, setPromise] = useState(null);
return (
<>
<button onClick={() => startTransition(() => { setAttempt(a => a + 1); setPromise(loadProfile()); })}>
{attempt ? 'Load again' : 'Load profile'}
</button>
{promise && (
// key: a NEW boundary each time. A mounted boundary that re-suspends
// inside a Transition keeps showing the old content instead of the fallback.
<Suspense key={attempt} fallback={<Skeleton />}>
<Profile promise={promise} />
</Suspense>
)}
</>
);
}
Los estilos base le dan al card y al skeleton su apariencia:
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: light-dark(#64748b, #94a3b8); background: light-dark(#e2e8f0, #2a2f3a); }
Las reglas de animación se aplican a las clases nombradas. ::view-transition-old(.sink) se utiliza para la captura del esqueleto anterior y ::view-transition-new(.rise) para el contenido que llega, cada uno con sus propios fotogramas clave:
::view-transition-old(.sink) { animation: 240ms ease-in both sink; }
::view-transition-new(.rise) { animation: 320ms ease-out both rise; }
@keyframes sink { to { transform: translateY(16px); opacity: 0; } }
@keyframes rise { from { transform: translateY(-16px); opacity: 0; } }
Esperando imágenes, fuentes y hojas de estilo
Los límites de Suspense han restringido durante mucho tiempo el acceso a datos y código cargado de forma perezosa. En React 19.3, el conjunto de elementos restringidos por un límite aumenta cuando la revelación se anima mediante un <ViewTransition>:
- Datos leídos con
use()y componentes perezosos: se esperan en ambos casos. - Hojas de estilo declaradas con
<link rel="stylesheet" precedence="…">: se esperan en ambos casos (con un tiempo de espera para Suspense normal).
<img> visibles: son ignorados por Suspense en su forma básica; durante la actualización de una transición de vista, React espera a que se decodifiquen durante un máximo de 800 ms, excepto en el caso de imágenes que usan loading="lazy" o tienen un manejador onLoad.La razón es visual. Animar hacia una tarjeta cuya foto solo está parcialmente cargada o cuyo texto sigue usando una fuente del sistema se ve peor que un simple cambio de vista. Por lo tanto, React pospone la revelación de una transición de vista pendiente hasta que las imágenes y las fuentes nuevas en ese área estén disponibles. Sin una transición de vista, nada cambia, y las imágenes siguen apareciendo en cuanto llegan.
Cuánto tiempo está dispuesto a esperar React
Los tiempos de espera están limitados. Estas especificaciones provienen de la lectura de la implementación de react-dom y no de la documentación, por lo que deben considerarse como detalles de implementación que pueden cambiar:
- Para las imágenes, React llama a
img.decode()y retrasa el procesamiento por un máximo de 800 ms. - También estima cuán pesada es cada imagen a partir de sus atributos
widthyheight, así como la velocidad de la red según las entradas de Resource Timing. Cuando esa estimación indica que la descarga tardaría más de aproximadamente 500 ms, React le asigna solo 50 ms y luego procede de todos modos. - Sin los valores de
widthyheight, la estimación asume que se trata de una imagen de 100×100, por lo que las dimensiones reales hacen que la estimación sea significativa. - Para los fuentes, el límite es de unos 500 ms.
Demostración del tiempo de espera de las imágenes
La demostración con imágenes importa algunos hooks adicionales:
import { ViewTransition, Suspense, use, useState, useRef, useLayoutEffect, startTransition } from 'react';
Cada clic carga una ficha con una URL de foto generada aleatoriamente, por lo que la imagen nunca se almacena en caché. Dos espacios muestran la misma tarjeta una al lado de la otra. El izquierdo permite que React espere a la foto; el derecho asigna un manejador onLoad vacío, lo que evita cargar la imagen, de modo que la tarjeta se muestra inmediatamente y la foto llega después. Un useLayoutEffect registra img.complete en el momento en que se muestra cada tarjeta, para que pueda confirmarse la diferencia en una red rápida donde es difícil observarla.
function fetchListing() {
return new Promise(r => setTimeout(() => r({
title: 'Sea-facing 2BHK, Bandra',
// a new seed every time → a fresh download every time
photo: `https://picsum.photos/seed/${Math.random().toString(36).slice(2)}/600/300`,
}), 300));
}function Listing({ promise, optOut, label }) {
const l = use(promise);
const img = useRef(null);
// runs at the moment the content is committed — was the photo already there?
useLayoutEffect(() => {
console.log(`${label}: photo decoded before reveal → ${img.current.complete}`);
}, [label]);
return (
<div className="card">
<img
ref={img}
src={l.photo}
width={600}
height={300}
alt=""
onLoad={optOut ? () => {} : undefined}
/>
<strong>{l.title}</strong>
</div>
);
}function Slot({ label, optOut }) {
const [promise, setPromise] = useState(null);
return (
<div>
<button onClick={() => startTransition(() => setPromise(fetchListing()))}>{label}</button>
{promise && (
<ViewTransition update="auto" default="none">
<Suspense fallback={<div className="card skeleton"><div className="ph" /><strong>loading…</strong></div>}>
<Listing promise={promise} optOut={optOut} label={label} />
</Suspense>
</ViewTransition>
)}
</div>
);
}export default function App() {
return (
<div className="two">
<Slot label="Wait for photo" optOut={false} />
<Slot label="onLoad opt-out" optOut={true} />
</div>
);
}
Los estilos organizan las dos columnas y reservan la relación de aspecto de la imagen tanto en la tarjeta como en el marcador de posición:
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.card { margin-top: 10px; padding: 10px; border-radius: 10px; background: light-dark(#f1f5f9, #262a33); }
.card img, .ph { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; border-radius: 6px; object-fit: cover; margin-bottom: 8px; }
.ph { background: light-dark(#e2e8f0, #363b47); }
.skeleton strong { color: light-dark(#64748b, #94a3b8); }
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Fuentes como recursos rastreables
Las fuentes siguen el mismo modelo. El truco consiste en declarar la regla @font-face dentro de un elemento <style> con tanto href como precedence, lo que indica a React que la coloque en una posición prioritaria y la considere como un recurso del cual puede esperar su carga:
<ViewTransition update="auto" default="none">
<Suspense fallback={<QuoteSkeleton />}>
<style href={fontUrl} precedence="default">
{`@font-face {
font-family: 'Quote';
src: url(${fontUrl}) format('woff2');
font-display: swap;
}`}
</style>
<blockquote style={{ fontFamily: 'Quote' }}>{quote}</blockquote>
</Suspense>
</ViewTransition>
La documentación de React incluye un ejemplo más completo que muestra datos obtenidos, una hoja de estilo con precedence, una fuente personalizada y una foto en formato vertical bajo un mismo esqueleto compartido, todo presentado juntos. Esa combinación corresponde a la mayoría de las tarjetas reales: un perfil, una ficha de producto y un mensaje de chat con avatar.
Cómo se hacía anteriormente
Antes de esto, para mantener el contenido hasta que la imagen estuviera lista era necesario realizar una carga previa manual: crear un Image, esperar a que se cargara con onload y luego modificar un estado. Un hook típico se veía así:
function useImageReady(src) {
const [ready, setReady] = useState(false);
useEffect(() => {
const img = new Image();
img.onload = () => setReady(true);
img.src = src;
}, [src]);
return ready;
}
Cada componente tenía su propia copia y ninguno de ellos se coordinaba, por lo que una tarjeta con foto y fuente personalizada seguía mostrándose en dos pasos visibles. La situación era aún peor con las fuentes, que requerían document.fonts.load() junto con una bandera especial, o font-display: block, lo que generaba un destello temporal de texto invisible. Para animar el cambio era necesario llamar manualmente a startViewTransition cuando llegaban los datos, y como esto pasaba por flushSync, el sistema no podía esperar nada por sí mismo.
Por qué es importante el cambio
La carga de la interfaz de usuario se reduce a una sola decisión en lugar de cuatro. El límite definido por Suspense también determina cuándo se considera listo el contenido, abarcando datos, código, estilos, fuentes e imágenes; la animación se ejecuta una sola vez, en ese momento. Los efectos específicos para cada recurso desaparecen, al igual que el código de enlace que intentaba sincronizarlos.
También le da a React la posibilidad de decidir cuándo el navegador dibuja por primera vez una imagen cargada. Anteriormente, un <img> se dibujaba tan pronto como se decodificaba. Al vincularlo a la transición de vista, se obtiene el comportamiento de “aparecer completo” de las aplicaciones nativas, sin necesidad de un precargue escrito a mano.
Puntos importantes a tener en cuenta
- Las fuentes e imágenes solo se esperan durante una actualización por transición de vista. Sin un
<ViewTransition>, o conupdate="none", las imágenes aparecen como antes. - Agregar un manejador
onLoadexcluye a esa imagen de la espera, independientemente de que haya transición de vista o no. Esto es útil para imágenes que se muestran debajo del despliegue, pero puede resultar sorprendente si el manejador se agregó con fines de análisis.
update="auto" default="none".next o back añadido a la actualización que mostraba el contenido alternativo ya no existe cuando se commita el contenido real.Contenido alternativo para versiones anteriores de React
Si aún no puede actualizar, la forma fiable de evitar que las imágenes aparezcan de repente es tratarla como parte de lo que se está cargando: suspender la operación en una Promise que solo se resuelva una vez que el JSON haya llegado y la foto haya sido decodificada:
function loadListing() {
return fetch('/api/listing').then(r => r.json()).then(async data => {
const img = new Image();
img.src = data.photo;
await img.decode().catch(() => {}); // don't let a broken image block forever
return data;
});
}
El .catch(() => {}) en decode() asegura que una imagen dañada no pueda bloquear la visualización para siempre. Aún se produce un cambio brusco en lugar de una animación, pero la imagen ya está en el caché al momento de la renderización y aparece en el mismo fotograma que el resto de la tarjeta. Las fuentes pueden gestionarse de manera similar mediante la cadena de promesas provenientes de document.fonts.load('1em Quote').
Puntos clave
- Al envolver Suspense dentro de
<ViewTransition>se anima el cambio al contenido alternativo sin necesidad de unstartTransitionexplícito para la visualización. - Utilice
update="auto" default="none"para que los contenidos alternativos y el contenido en caché aparezcan de inmediato, y solo se anime la visualización real.
<ViewTransition> fuera de Suspense para un solo fundido cruzado, o dentro de ambas ramas para animaciones independientes de entrada y salida.width y height reales, y recuerde que onLoad las excluye de este proceso.Referencias principales: las notas de lanzamiento de React 19.3, la referencia de Suspense, la referencia de ViewTransition y la referencia del componente style.
Lecturas relacionadas
- Recuperación paralela en React: Suspense con Promise.all y allSettled — Aprenda cómo eliminar las secuencias de solicitudes con Promise.all, evitar que los datos opcionales dañen las páginas con Promise.allSettled y mostrar estados de carga con Suspense.
- Deduplicación de consultas ORM en un renderizado Next.js con React cache() — Entienda por qué los componentes del servidor colocados pueden consultar el mismo registro varias veces por solicitud, cómo confirmarlo y cómo React cache() lo soluciona sin necesidad de pasar propiedades en profundidad.