← Volver al blog CSS View Transitions: transiciones entre páginas sin convertir tu sitio en SPA
cssfrontendweb-developmentview-transitionsuxspampa

CSS View Transitions: transiciones entre páginas sin convertir tu sitio en SPA

Guía completa de la View Transitions API: transiciones entre páginas de una MPA y entre estados de una SPA con CSS. Modelo mental, elemento compartido, ejemplos en vanilla, React y Angular, errores comunes del mundo real y cómo depurarlos.

Durante años, las transiciones fluidas entre pantallas fueron una ventaja casi exclusiva de las aplicaciones de una sola página. Si querías animar el cambio entre una vista y otra, normalmente terminabas escribiendo JavaScript, duplicando estados del DOM, peleando con overlays, o convirtiendo un sitio multipágina en una SPA solo para controlar la navegación.

Eso está cambiando con la View Transitions API. La idea central es simple: en lugar de que tú captures el estado visual de "antes" y "después" y los animes a mano, le pides al navegador que lo haga por ti. Él toma una foto del estado actual, deja que cambies el DOM (o que se cargue la página nueva) y luego anima desde la foto vieja hacia la nueva.

La novedad importante para frontend es que hoy esto funciona en dos niveles: entre estados dentro de una misma página (el caso SPA), y entre páginas reales de una aplicación multipágina —conocidas como MPA— usando solo CSS. Para el caso multipágina, la pieza clave es un at-rule:

@view-transition {
  navigation: auto;
}

Con esa línea, dos documentos del mismo origen pueden optar por participar en una transición al navegar entre ellos. No reemplaza a todas las librerías de animación ni convierte mágicamente cualquier navegación en algo perfecto, pero cambia el punto de partida: ahora se puede lograr una sensación de app moderna manteniendo páginas reales, SSR, frameworks tradicionales o sitios estáticos.

Demos visuales

Primero, el caso más llamativo: un elemento compartido viaja desde una tarjeta hacia el hero de la página de detalle.

Transición de elemento compartido: la imagen de la tarjeta se convierte en el hero del artículo.

Y este es el caso base: sin elementos compartidos, solo una transición tipo fade entre documentos completos.

Transición root: una página se desvanece mientras el siguiente documento aparece en su lugar.

Qué problema resuelve

Las transiciones entre páginas tienen dos objetivos principales: reducir el salto visual entre una pantalla y otra, y mantener el contexto del usuario cuando navega.

Sin View Transitions, una navegación tradicional suele sentirse como un corte seco: click, parpadeo o cambio repentino, página nueva. Eso está bien para muchos casos, pero en interfaces visuales puede sentirse tosco. Piensa en situaciones como:

  • Una galería donde haces click en una tarjeta y la imagen se expande en el detalle.
  • Un blog donde el título o el hero de un artículo parece continuar desde la lista.
  • Una tienda donde la foto de un producto se transforma hacia su página.
  • Un dashboard donde una tarjeta pasa a ocupar una vista detallada.
  • Documentación donde el contenido se desliza suavemente entre secciones.

Antes, para lograr esto con elegancia había que controlar la navegación desde JavaScript. Ahora, para muchos casos, el navegador puede hacer el trabajo pesado.

Cómo funciona por dentro

Vale la pena entender el modelo mental, porque casi todo lo demás se deriva de él.

Cuando ocurre una transición, el navegador sigue estos pasos:

  1. Captura el estado viejo. Toma una foto (un snapshot) de la página tal como se ve ahora.
  2. Aplica el cambio. En una SPA, ejecuta tu callback que muta el DOM. En una MPA, carga el documento nuevo.
  3. Captura el estado nuevo. Toma otra foto, ya con el resultado.
  4. Construye un árbol de pseudo-elementos por encima de toda la página y anima de la foto vieja a la nueva.

Ese árbol es la parte que conviene tener clara, porque es lo que vas a estilizar con CSS:

::view-transition                        (capa que cubre toda la pantalla)
└─ ::view-transition-group(name)         (un grupo por cada nombre + "root")
   └─ ::view-transition-image-pair(name) (contiene el par viejo/nuevo)
      ├─ ::view-transition-old(name)     (snapshot del estado anterior)
      └─ ::view-transition-new(name)     (snapshot del estado nuevo)

La clave está en separar dos responsabilidades:

  • El group anima posición y tamaño. Es lo que hace que un elemento parezca "volar" y crecer de un lugar a otro.
  • El old y el new hacen el crossfade del contenido: el viejo se desvanece y el nuevo aparece.

Por defecto, todo el documento participa con un nombre implícito llamado root. Como root no cambia de tamaño ni posición entre páginas, su group no anima nada visible y solo ves el crossfade (el fade entre páginas). Cuando le das un nombre propio a un elemento que sí cambia de caja entre estados, su group anima esa caja y obtienes el efecto de "morph".

Con este modelo en la cabeza, los ejemplos dejan de ser recetas mágicas: sabes exactamente qué pseudo-elemento estás tocando y por qué.

Dos escenarios: mismo documento (SPA) y entre documentos (MPA)

La API tiene dos modos conceptuales.

Mismo documento (SPA)

La página no se recarga; el JavaScript actualiza el DOM y envuelves ese cambio:

const transition = document.startViewTransition(() => {
  // Aquí mutas el DOM (insertas, reemplazas, reordenas).
  updateTheDOM();
});

startViewTransition devuelve un objeto con varias promesas útiles:

const transition = document.startViewTransition(updateTheDOM);

transition.updateCallbackDone; // el callback (cambio de DOM) terminó
transition.ready;              // los pseudo-elementos existen y la animación va a empezar
transition.finished;           // la animación terminó

// Y puedes cancelar la animación (no el cambio de DOM):
// transition.skipTransition();

ready es especialmente útil: es el momento exacto para añadir animaciones con la Web Animations API si prefieres JS en lugar de CSS.

Entre documentos (MPA)

Este es el caso más interesante para sitios multipágina, y no requiere JavaScript. No llamas startViewTransition(): la transición ocurre cuando el usuario navega entre dos páginas del mismo origen y ambas optan por participar.

@view-transition {
  navigation: auto;
}

Ese CSS debe estar disponible tanto en la página de origen como en la de destino. Por ejemplo:

<!-- /index.html -->
<link rel="stylesheet" href="/styles.css">
<a href="/producto/zapatillas">Ver producto</a>
<!-- /producto/zapatillas.html -->
<link rel="stylesheet" href="/styles.css">
<h1>Zapatillas edición limitada</h1>
/* /styles.css */
@view-transition {
  navigation: auto;
}

Con eso ya obtienes una transición por defecto: normalmente un cross-fade suave entre la página anterior y la nueva, si el navegador lo soporta.

Condiciones importantes

Las transiciones entre documentos no se activan para cualquier navegación. Hay reglas:

  • Mismo origen. Mismo esquema, host y puerto. Una navegación de https://example.com/blog a https://example.com/about puede participar; una hacia https://otro-dominio.com no.
  • Ambas páginas optan. Las dos deben tener @view-transition { navigation: auto }.
  • El navegador puede saltarse la transición si la navegación tarda demasiado. Chrome documenta que si pasa de unos segundos, omite la animación para no bloquear la experiencia.
  • Es mejora progresiva. Si el navegador no soporta la API, la navegación sigue funcionando con el cambio tradicional.

Ejemplo 1: fade entre páginas

El ejemplo más sencillo —y el más seguro para empezar— es un fade entre el documento anterior y el nuevo. Como vimos, afecta al snapshot root, así que no requiere coordinar elementos específicos.

@view-transition {
  navigation: auto;
}

::view-transition-old(root) {
  animation: fade-out 180ms ease both;
}

::view-transition-new(root) {
  animation: fade-in 220ms ease both;
}

@keyframes fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

Ejemplo 2: slide direccional

Para navegación entre secciones, un slide puede comunicar mejor la dirección del cambio.

@view-transition {
  navigation: auto;
}

::view-transition-old(root) {
  animation: slide-out 240ms cubic-bezier(.4, 0, .2, 1) both;
}

::view-transition-new(root) {
  animation: slide-in 240ms cubic-bezier(.4, 0, .2, 1) both;
}

@keyframes slide-out {
  to {
    opacity: 0;
    transform: translateX(-24px);
  }
}

@keyframes slide-in {
  from {
    opacity: 0;
    transform: translateX(24px);
  }
}

Úsalo con cuidado: demasiado movimiento cansa. En sitios de contenido conviene que sea sutil.

Ejemplo 3: respetar prefers-reduced-motion

Esto es obligatorio para una implementación responsable. Hay personas que prefieren reducir animaciones por comodidad, accesibilidad o porque les producen mareo.

@view-transition {
  navigation: auto;
}

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

La navegación sigue funcionando igual; simplemente desaparece el movimiento.

Ejemplo 4: elemento compartido entre lista y detalle

Aquí es donde brilla la API. Para que un elemento se anime por separado del resto de la página, le das un view-transition-name y usas el mismo nombre en las dos pantallas. El navegador entiende que son "el mismo" elemento y anima su group (posición y tamaño) de una caja a la otra.

En la lista:

<a class="post-card" href="/blog/css-view-transitions">
  <img
    class="post-card__image"
    src="/images/view-transitions-card.jpg"
    alt="Interfaz web con transición entre páginas"
  >
  <h2>CSS View Transitions</h2>
</a>

En el detalle:

<article class="post">
  <img
    class="post__hero"
    src="/images/view-transitions-card.jpg"
    alt="Interfaz web con transición entre páginas"
  >
  <h1>CSS View Transitions: transiciones entre páginas</h1>
</article>

Asignas el mismo nombre a ambos elementos:

.post-card__image,
.post__hero {
  view-transition-name: article-hero;
}

Y, si quieres, personalizas su tiempo y curva:

::view-transition-old(article-hero),
::view-transition-new(article-hero) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(.2, 0, 0, 1);
}

El punto crítico: los nombres deben ser únicos entre los elementos renderizados al mismo tiempo. Si dos elementos visibles comparten el mismo view-transition-name, el navegador no sabe cuál animar y aborta la transición.

Ejemplo 5: listas con nombres únicos

En una lista con muchas tarjetas no puedes darle a todas el mismo nombre fijo:

/* Mala idea si hay muchas tarjetas visibles: crea nombres duplicados */
.card-image {
  view-transition-name: product-image;
}

La estrategia es generar un nombre único por item, normalmente desde el id o slug:

<a class="product-card" href="/products/42">
  <img
    src="/products/42.jpg"
    alt="Cámara compacta"
    style="view-transition-name: product-42-image"
  >
  <h2 style="view-transition-name: product-42-title">Cámara compacta</h2>
</a>

Y en el detalle, el mismo nombre:

<img
  src="/products/42.jpg"
  alt="Cámara compacta"
  style="view-transition-name: product-42-image"
>
<h1 style="view-transition-name: product-42-title">Cámara compacta</h1>

Con cualquier plantilla del lado del servidor (PHP, Django, Rails, Laravel Blade) o framework de componentes (React, Vue, Angular, Svelte), ese nombre puede generarse con el id o slug del recurso.

Ejemplo 6: React y Angular

En aplicaciones SPA la idea es la misma que en vanilla: envuelves el cambio de estado o de ruta con startViewTransition. Solo cambia quién dispara ese cambio.

En React, para una interacción dentro del mismo documento (por ejemplo, cambiar de pestaña):

function changeTab(nextTab: string) {
  if (!document.startViewTransition) {
    setTab(nextTab);
    return;
  }

  document.startViewTransition(() => {
    setTab(nextTab);
  });
}

En Angular (17+), el router trae transiciones de vista integradas. Se activan al configurar el router, sin escribir startViewTransition a mano:

import { provideRouter, withViewTransitions } from '@angular/router';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withViewTransitions()),
  ],
});

Con eso, cada cambio de ruta queda envuelto automáticamente en document.startViewTransition. Para cambios que no son de navegación, puedes llamar la API directamente, igual que en vanilla JS.

El CSS es idéntico en ambos casos: defines los nombres y animas los pseudo-elementos.

.tab-panel {
  view-transition-name: active-tab-panel;
}

La diferencia entre frameworks no está en la API de transiciones, sino en cómo cada uno actualiza el DOM. La capa de CSS no cambia.

Ejemplo 7: animar el cambio de tema

La API también sirve para estados del mismo documento. Un caso popular es animar el cambio entre tema claro y oscuro.

const button = document.querySelector('[data-theme-toggle]');

button.addEventListener('click', () => {
  const updateTheme = () => {
    document.documentElement.classList.toggle('dark');
  };

  if (!document.startViewTransition) {
    updateTheme();
    return;
  }

  document.startViewTransition(updateTheme);
});
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 250ms;
}

Con un poco más de CSS se pueden crear efectos tipo wipe radial, pero conviene no exagerar si la app es de productividad.

Ejemplo 8: excluir elementos problemáticos

A veces no quieres que un elemento participe como snapshot separado, o quieres evitar que genere artefactos visuales. Le quitas el nombre:

.video-player,
.live-widget,
.no-transition {
  view-transition-name: none;
}

Es útil con videos, mapas, componentes con canvas, widgets de terceros, elementos sticky complejos, anuncios e iframes.

El árbol de pseudo-elementos (referencia)

Para tener la referencia a mano, estos son los pseudo-elementos que expone una transición y qué controla cada uno:

/* Toda la página (snapshot implícito "root") */
::view-transition-old(root) {}
::view-transition-new(root) {}

/* Un elemento nombrado */
::view-transition-group(article-hero) {}      /* posición y tamaño */
::view-transition-image-pair(article-hero) {} /* contenedor del par */
::view-transition-old(article-hero) {}         /* contenido anterior */
::view-transition-new(article-hero) {}          /* contenido nuevo */

También puedes apuntar a todos los grupos a la vez con el selector universal, útil para fijar una duración o curva globales:

::view-transition-group(*) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(.2, 0, 0, 1);
}

Recuerda la división de trabajo: el group mueve y redimensiona; el old/new hace el crossfade del contenido.

Errores comunes del mundo real

Estos son los problemas que más aparecen al implementarlo de verdad (no en una demo):

La navegación hace una recarga completa y no hay transición

Las transiciones entre documentos solo ocurren si el navegador hace una navegación "suave" del mismo origen. Si el enlace redirige (por ejemplo, la URL canónica lleva barra final y tu link no, y el servidor responde un 301), muchos routers caen a una recarga completa de la página, y la transición no se dispara. Síntoma típico: ves la transición en una dirección pero no en la otra. Solución: que los enlaces apunten a la URL final, sin redirects intermedios.

El elemento compartido "no vuela", solo hace fade

Si el morph se siente como un simple fade, casi siempre es porque el group (que mueve y redimensiona) dura muy poco o queda tapado por el crossfade de root. Dale al group algo más de duración y mantén el fade de página más corto, para que la atención vaya al elemento que viaja.

La imagen parpadea o se recarga

Si la imagen de la tarjeta y la del detalle usan URLs distintas (otro tamaño o recorte), el navegador las trata como recursos diferentes y descarga la del detalle de cero, lo que produce un parpadeo en mitad de la transición. Usa la misma URL en ambas para que el navegador reutilice el bitmap ya cargado y cacheado.

Imágenes con loading="lazy" o fuera del viewport

Un elemento que aún no se pintó (por carga diferida o porque está fuera de pantalla) puede capturarse en blanco. Asegúrate de que el elemento esté renderizado antes de que arranque la transición.

Nombres duplicados

Dos elementos visibles con el mismo view-transition-name abortan la transición. Mantén los nombres únicos por elemento renderizado.

Navegadores sin soporte y reduced-motion

Nunca dependas de la animación para comunicar estado. Si no hay soporte o el usuario pidió menos movimiento, la navegación debe seguir entendiéndose perfectamente.

Cómo depurar transiciones

Las transiciones son rápidas, así que conviene poder verlas en cámara lenta:

  • Ralentízalas temporalmente con CSS mientras desarrollas. Es la técnica más práctica:

    ::view-transition-group(*) {
      animation-duration: 3s !important;
    }
  • Inspecciona los pseudo-elementos. Durante la transición, el árbol ::view-transition-* cuelga del :root. En las DevTools de Chrome puedes verlos y revisar sus estilos calculados.

  • El panel Animations de Chrome captura la transición y permite reproducirla más lento.

  • getAnimations() te deja listar por código las animaciones activas, incluidas las de los pseudo-elementos, para confirmar que la transición realmente se generó:

    document.getAnimations().forEach((a) => console.log(a.effect?.pseudoElement));

Soporte de navegadores

El soporte ha avanzado, pero conviene tratarlo como mejora progresiva:

  • Chrome y Edge soportan View Transitions del mismo documento desde hace varias versiones, y transiciones entre documentos (MPA) desde Chrome 126.
  • Safari soporta View Transitions del mismo documento en versiones modernas, y entre documentos a partir de Safari 18.2.
  • Firefox va más atrás: valida según versión, porque ha estado detrás de flags o en desarrollo.
  • MDN marca @view-transition como de disponibilidad limitada, justamente porque no se comporta igual en todos los navegadores ampliamente usados.

La recomendación práctica: úsalo sin romper la navegación normal.

Feature detection

Para SPA puedes detectar la API antes de usarla:

if ('startViewTransition' in document) {
  document.startViewTransition(() => updateDOM());
} else {
  updateDOM();
}

Para MPA, el opt-in por CSS no necesita JavaScript. Si quieres aislar estilos, puedes usar @supports:

@supports (view-transition-name: root) {
  @view-transition {
    navigation: auto;
  }
}

Aun así, un bloque @view-transition desconocido será ignorado por los navegadores que no lo entiendan, así que normalmente puedes incluirlo directo.

Buenas prácticas

  • Mantén las animaciones cortas. Una transición entre páginas debería sentirse como continuidad, no como una intro. Entre 150 ms y 350 ms suele bastar.
  • No animes todo con desplazamientos grandes. El fade es aburrido pero seguro; los slides y zooms grandes lucen en demos pero cansan en uso diario.
  • Usa nombres únicos. view-transition-name debe ser único por elemento visible; para listas, genera nombres con IDs.
  • Respeta la accesibilidad. Incluye prefers-reduced-motion y cuida el foco, sobre todo en SPAs.
  • Evita elementos frágiles. Videos, iframes, canvas, mapas y widgets complejos pueden generar snapshots raros: pruébalos o exclúyelos.
  • No dependas de la transición para comunicar estado. La navegación debe entenderse aunque la animación no ocurra.

Cuándo usarlo y cuándo no

Tiene mucho sentido en blogs visuales, portafolios, tiendas, galerías, documentación con navegación lateral, dashboards y apps internas donde la continuidad espacial ayuda.

No siempre vale la pena en formularios críticos, flujos de pago sensibles, interfaces donde el movimiento distrae, o páginas muy lentas con mucho contenido dinámico.

Qué significa para el frontend

La View Transitions API es importante porque reduce la distancia entre una MPA tradicional y la sensación fluida de una SPA. Y eso importa porque una MPA suele tener ventajas claras: simplicidad mental, buen soporte para SSR, menos JavaScript en cliente, navegación natural del navegador y mejor compatibilidad con cache, enlaces y fallback.

Antes, si querías transiciones bonitas, muchas veces terminabas aceptando más JavaScript del necesario. Ahora puedes empezar con CSS y dejar que el navegador haga buena parte del trabajo.

Conclusión

CSS y la plataforma web moderna ya soportan transiciones entre páginas. La clave para MPAs es:

@view-transition {
  navigation: auto;
}

Y para elementos compartidos, un nombre único en ambas pantallas:

.elemento {
  view-transition-name: nombre-unico;
}

Todavía debe usarse como mejora progresiva, pero ya es suficientemente práctico para sitios reales, sobre todo si tu audiencia usa navegadores modernos. Lo interesante no es solo que las páginas se vean mejor: es que la web recupera una capacidad que antes empujaba a muchos equipos hacia SPAs más pesadas —continuidad visual, navegación fluida y animaciones declarativas— sin abandonar el modelo simple de documentos.

Fuentes oficiales y referencias

UN CAFÉ

¿Te sirvió algo de esto?

Escribo todo esto en mi tiempo libre, por gusto. Si algo te sirvió y te nace, invitame un café. Sin obligación — con que te sea útil ya estoy contento.

Invitame un café

Comentarios

Cargando comentarios…