View Transitions API: Guía Completa
La View Transitions API revoluciona la forma en que creamos transiciones entre estados y páginas en aplicaciones web, permitiendo experiencias fluidas y atractivas similares a las aplicaciones nativas. Esta guía completa cubre desde los fundamentos hasta técnicas avanzadas para implementar transiciones de vista.
Tabla de Contenidos
- ¿Qué es View Transitions API?
- Soporte de Navegadores
- Transiciones Same-Document (SPA)
- Transiciones Cross-Document (MPA)
- Personalización con CSS
- Transiciones Avanzadas
- View Transition Types
- Casos de Uso Prácticos
- Rendimiento y Mejores Prácticas
- Compatibilidad y Fallbacks
¿Qué es View Transitions API?
La View Transitions API proporciona un mecanismo para crear transiciones animadas entre diferentes vistas de un sitio web. Históricamente, crear transiciones fluidas ha sido complejo y ha requerido:
- Código CSS y JavaScript significativo
- Manejo manual de estados "antes" y "después"
- Gestión de accesibilidad con contenido duplicado
- Sincronización cuidadosa de animaciones
La View Transitions API resuelve estos problemas al:
- Capturar snapshots del estado actual de la página
- Actualizar el DOM (o navegar a nueva página)
- Animar automáticamente entre los dos estados
- Manejar accesibilidad correctamente
Tipos de Transiciones
Same-Document Transitions (SPA)
Transiciones dentro de la misma página, típicas en Single Page Applications:
- Cambios de contenido dinámico
- Filtrado y ordenamiento de listas
- Cambios de tema
- Actualización de estados de UI
Cross-Document Transitions (MPA)
Transiciones entre diferentes páginas HTML, típicas en Multi-Page Applications:
- Navegación entre páginas
- Experiencia similar a SPA sin framework pesado
- Transiciones seamless en sitios tradicionales
Soporte de Navegadores
Estado Actual (2025)
Chrome/Edge: 111+ (desde marzo 2023)
Safari: 18+ (desde septiembre 2024)
Firefox: 144+ (octubre 2025)Chrome/Edge: 111+ (desde marzo 2023)
Safari: 18+ (desde septiembre 2024)
Firefox: 144+ (octubre 2025)Interop 2025: View Transitions es un área de enfoque, garantizando implementación consistente.
Características por Navegador
| Característica | Chrome | Safari | Firefox |
|---|---|---|---|
| Same-document transitions | 111+ | 18+ | 144+ |
| Cross-document transitions | 126+ | 18+ | Próximamente |
| view-transition-class | 125+ | 18+ | 144+ |
| View transition types | 125+ | Parcial | No en inicial |
| match-element | 129+ | 18+ | 144+ |
Transiciones Same-Document (SPA)
Implementación Básica
// Verificar soporte
if (!document.startViewTransition) {
// Fallback: actualizar DOM directamente
updateDOM();
return;
}
// Iniciar transición
const transition = document.startViewTransition(() => {
// Actualizar el DOM aquí
updateDOM();
});
// Opcional: reaccionar a diferentes estados
transition.ready.then(() => {
console.log('Transición lista para animar');
});
transition.finished.then(() => {
console.log('Transición completada');
});// Verificar soporte
if (!document.startViewTransition) {
// Fallback: actualizar DOM directamente
updateDOM();
return;
}
// Iniciar transición
const transition = document.startViewTransition(() => {
// Actualizar el DOM aquí
updateDOM();
});
// Opcional: reaccionar a diferentes estados
transition.ready.then(() => {
console.log('Transición lista para animar');
});
transition.finished.then(() => {
console.log('Transición completada');
});Ejemplo: Lista de Tareas
// HTML inicial
<ul id="task-list">
<li data-id="1">Comprar leche</li>
<li data-id="2">Llamar al médico</li>
<li data-id="3">Revisar emails</li>
</ul>
// JavaScript
function removeTask(taskId) {
if (!document.startViewTransition) {
// Fallback sin animación
document.querySelector(`[data-id="${taskId}"]`).remove();
return;
}
document.startViewTransition(() => {
document.querySelector(`[data-id="${taskId}"]`).remove();
});
}
// CSS para personalizar transición
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
::view-transition-old(root) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-in;
}// HTML inicial
<ul id="task-list">
<li data-id="1">Comprar leche</li>
<li data-id="2">Llamar al médico</li>
<li data-id="3">Revisar emails</li>
</ul>
// JavaScript
function removeTask(taskId) {
if (!document.startViewTransition) {
// Fallback sin animación
document.querySelector(`[data-id="${taskId}"]`).remove();
return;
}
document.startViewTransition(() => {
document.querySelector(`[data-id="${taskId}"]`).remove();
});
}
// CSS para personalizar transición
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
::view-transition-old(root) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-in;
}Con Frameworks
React
import { useState } from 'react';
function TaskList() {
const [tasks, setTasks] = useState([
{ id: 1, text: 'Comprar leche' },
{ id: 2, text: 'Llamar al médico' }
]);
const removeTask = (id) => {
// Envolver actualización de estado en transición
if (!document.startViewTransition) {
setTasks(tasks => tasks.filter(t => t.id !== id));
return;
}
document.startViewTransition(() => {
// React actualizará el DOM de forma síncrona
// usando flushSync
import('react-dom').then(({ flushSync }) => {
flushSync(() => {
setTasks(tasks => tasks.filter(t => t.id !== id));
});
});
});
};
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
{task.text}
<button onClick={() => removeTask(task.id)}></button>
</li>
))}
</ul>
);
}import { useState } from 'react';
function TaskList() {
const [tasks, setTasks] = useState([
{ id: 1, text: 'Comprar leche' },
{ id: 2, text: 'Llamar al médico' }
]);
const removeTask = (id) => {
// Envolver actualización de estado en transición
if (!document.startViewTransition) {
setTasks(tasks => tasks.filter(t => t.id !== id));
return;
}
document.startViewTransition(() => {
// React actualizará el DOM de forma síncrona
// usando flushSync
import('react-dom').then(({ flushSync }) => {
flushSync(() => {
setTasks(tasks => tasks.filter(t => t.id !== id));
});
});
});
};
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
{task.text}
<button onClick={() => removeTask(task.id)}></button>
</li>
))}
</ul>
);
}Vue
<template>
<ul>
<li v-for="task in tasks" :key="task.id">
{{ task.text }}
<button @click="removeTask(task.id)"></button>
</li>
</ul>
</template>
<script setup>
import { ref, nextTick } from 'vue';
const tasks = ref([
{ id: 1, text: 'Comprar leche' },
{ id: 2, text: 'Llamar al médico' }
]);
async function removeTask(id) {
if (!document.startViewTransition) {
tasks.value = tasks.value.filter(t => t.id !== id);
return;
}
document.startViewTransition(async () => {
tasks.value = tasks.value.filter(t => t.id !== id);
await nextTick(); // Esperar a que Vue actualice el DOM
});
}
</script><template>
<ul>
<li v-for="task in tasks" :key="task.id">
{{ task.text }}
<button @click="removeTask(task.id)"></button>
</li>
</ul>
</template>
<script setup>
import { ref, nextTick } from 'vue';
const tasks = ref([
{ id: 1, text: 'Comprar leche' },
{ id: 2, text: 'Llamar al médico' }
]);
async function removeTask(id) {
if (!document.startViewTransition) {
tasks.value = tasks.value.filter(t => t.id !== id);
return;
}
document.startViewTransition(async () => {
tasks.value = tasks.value.filter(t => t.id !== id);
await nextTick(); // Esperar a que Vue actualice el DOM
});
}
</script>Transiciones Cross-Document (MPA)
Opt-in con CSS
Para habilitar transiciones entre páginas diferentes:
/* En tu style.css global */
@view-transition {
navigation: auto; /* Habilita transiciones automáticas */
}/* En tu style.css global */
@view-transition {
navigation: auto; /* Habilita transiciones automáticas */
}Importante: Este CSS debe estar presente en ambas páginas (origen y destino).
Ejemplo Completo: Blog
Página de Listado (index.html)
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Blog Posts</h1>
<div class="post-grid">
<article>
<img src="post1.jpg" alt="Post 1" style="view-transition-name: post-image-1;">
<h2><a href="post1.html">Título del Post 1</a></h2>
</article>
<article>
<img src="post2.jpg" alt="Post 2" style="view-transition-name: post-image-2;">
<h2><a href="post2.html">Título del Post 2</a></h2>
</article>
</div>
</body>
</html><!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Blog Posts</h1>
<div class="post-grid">
<article>
<img src="post1.jpg" alt="Post 1" style="view-transition-name: post-image-1;">
<h2><a href="post1.html">Título del Post 1</a></h2>
</article>
<article>
<img src="post2.jpg" alt="Post 2" style="view-transition-name: post-image-2;">
<h2><a href="post2.html">Título del Post 2</a></h2>
</article>
</div>
</body>
</html>Página de Detalle (post1.html)
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<article>
<img src="post1.jpg" alt="Post 1" style="view-transition-name: post-image-1;">
<h1>Título del Post 1</h1>
<p>Contenido completo del post...</p>
</article>
<a href="index.html"><- Volver</a>
</body>
</html><!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<article>
<img src="post1.jpg" alt="Post 1" style="view-transition-name: post-image-1;">
<h1>Título del Post 1</h1>
<p>Contenido completo del post...</p>
</article>
<a href="index.html"><- Volver</a>
</body>
</html>CSS (style.css)
/* Habilitar transiciones cross-document */
@view-transition {
navigation: auto;
}
/* La imagen con el mismo view-transition-name
se animará automáticamente entre páginas */
img {
contain: layout;
max-width: 100%;
}
/* Personalizar transiciones */
::view-transition-old(root) {
animation: fade-out 0.4s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.4s ease-in;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}/* Habilitar transiciones cross-document */
@view-transition {
navigation: auto;
}
/* La imagen con el mismo view-transition-name
se animará automáticamente entre páginas */
img {
contain: layout;
max-width: 100%;
}
/* Personalizar transiciones */
::view-transition-old(root) {
animation: fade-out 0.4s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.4s ease-in;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}Transiciones Direccionales
/* Detectar dirección de navegación */
@view-transition {
navigation: auto;
}
/* Navegación hacia adelante (forward) */
:root:active-view-transition-type(forward) {
&::view-transition-old(root) {
animation: slide-out-left 0.3s ease-out;
}
&::view-transition-new(root) {
animation: slide-in-right 0.3s ease-in;
}
}
/* Navegación hacia atrás (back) */
:root:active-view-transition-type(back) {
&::view-transition-old(root) {
animation: slide-out-right 0.3s ease-out;
}
&::view-transition-new(root) {
animation: slide-in-left 0.3s ease-in;
}
}
@keyframes slide-out-left {
to { transform: translateX(-100%); }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
}
@keyframes slide-out-right {
to { transform: translateX(100%); }
}
@keyframes slide-in-left {
from { transform: translateX(-100%); }
}/* Detectar dirección de navegación */
@view-transition {
navigation: auto;
}
/* Navegación hacia adelante (forward) */
:root:active-view-transition-type(forward) {
&::view-transition-old(root) {
animation: slide-out-left 0.3s ease-out;
}
&::view-transition-new(root) {
animation: slide-in-right 0.3s ease-in;
}
}
/* Navegación hacia atrás (back) */
:root:active-view-transition-type(back) {
&::view-transition-old(root) {
animation: slide-out-right 0.3s ease-out;
}
&::view-transition-new(root) {
animation: slide-in-left 0.3s ease-in;
}
}
@keyframes slide-out-left {
to { transform: translateX(-100%); }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
}
@keyframes slide-out-right {
to { transform: translateX(100%); }
}
@keyframes slide-in-left {
from { transform: translateX(-100%); }
}Personalización con CSS
Pseudo-elementos de View Transition
Cada transición genera pseudo-elementos que puedes estilizar:
::view-transition
└─ ::view-transition-group(name)
└─ ::view-transition-image-pair(name)
├─ ::view-transition-old(name)
└─ ::view-transition-new(name)::view-transition
└─ ::view-transition-group(name)
└─ ::view-transition-image-pair(name)
├─ ::view-transition-old(name)
└─ ::view-transition-new(name)Nombres de Transición Personalizados
<div class="card" style="view-transition-name: card-1;">
<h2>Card 1</h2>
</div><div class="card" style="view-transition-name: card-1;">
<h2>Card 1</h2>
</div>/* Animar específicamente este elemento */
::view-transition-old(card-1),
::view-transition-new(card-1) {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(card-1) {
animation-name: card-shrink;
}
::view-transition-new(card-1) {
animation-name: card-grow;
}
@keyframes card-shrink {
to {
transform: scale(0.8);
opacity: 0;
}
}
@keyframes card-grow {
from {
transform: scale(0.8);
opacity: 0;
}
}/* Animar específicamente este elemento */
::view-transition-old(card-1),
::view-transition-new(card-1) {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(card-1) {
animation-name: card-shrink;
}
::view-transition-new(card-1) {
animation-name: card-grow;
}
@keyframes card-shrink {
to {
transform: scale(0.8);
opacity: 0;
}
}
@keyframes card-grow {
from {
transform: scale(0.8);
opacity: 0;
}
}Auto-naming con match-element
Para listas grandes donde nombrar cada elemento manualmente es tedioso:
/* Antes (tedioso) */
.card:nth-child(1) { view-transition-name: card-1; }
.card:nth-child(2) { view-transition-name: card-2; }
/* ... hasta 100 elementos */
/* Ahora (automático) */
.card {
view-transition-name: match-element;
contain: layout;
}/* Antes (tedioso) */
.card:nth-child(1) { view-transition-name: card-1; }
.card:nth-child(2) { view-transition-name: card-2; }
/* ... hasta 100 elementos */
/* Ahora (automático) */
.card {
view-transition-name: match-element;
contain: layout;
}El navegador genera automáticamente nombres únicos basados en la identidad del elemento.
View Transition Classes
Agrupa elementos para aplicar estilos comunes:
/* HTML */
<div style="view-transition-name: header; view-transition-class: slide-from-top;">
Header
</div>
<main style="view-transition-name: content; view-transition-class: fade;">
Content
</main>
/* CSS */
html::view-transition-group(.slide-from-top) {
animation-duration: 0.6s;
}
html::view-transition-old(.slide-from-top) {
animation-name: slide-out-top;
}
html::view-transition-new(.slide-from-top) {
animation-name: slide-in-top;
}
html::view-transition-group(.fade) {
animation-duration: 0.3s;
}
@keyframes slide-out-top {
to { transform: translateY(-100%); }
}
@keyframes slide-in-top {
from { transform: translateY(-100%); }
}/* HTML */
<div style="view-transition-name: header; view-transition-class: slide-from-top;">
Header
</div>
<main style="view-transition-name: content; view-transition-class: fade;">
Content
</main>
/* CSS */
html::view-transition-group(.slide-from-top) {
animation-duration: 0.6s;
}
html::view-transition-old(.slide-from-top) {
animation-name: slide-out-top;
}
html::view-transition-new(.slide-from-top) {
animation-name: slide-in-top;
}
html::view-transition-group(.fade) {
animation-duration: 0.3s;
}
@keyframes slide-out-top {
to { transform: translateY(-100%); }
}
@keyframes slide-in-top {
from { transform: translateY(-100%); }
}Transiciones Avanzadas
Transición Circular desde Click
let lastClick;
document.addEventListener('click', (e) => {
lastClick = e;
});
function navigate() {
if (!document.startViewTransition) {
updateDOM();
return;
}
const x = lastClick?.clientX ?? window.innerWidth / 2;
const y = lastClick?.clientY ?? window.innerHeight / 2;
const transition = document.startViewTransition(() => {
updateDOM();
});
transition.ready.then(() => {
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
);
document.documentElement.animate(
{
clipPath: [
`circle(0 at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`
]
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)'
}
);
});
}let lastClick;
document.addEventListener('click', (e) => {
lastClick = e;
});
function navigate() {
if (!document.startViewTransition) {
updateDOM();
return;
}
const x = lastClick?.clientX ?? window.innerWidth / 2;
const y = lastClick?.clientY ?? window.innerHeight / 2;
const transition = document.startViewTransition(() => {
updateDOM();
});
transition.ready.then(() => {
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
);
document.documentElement.animate(
{
clipPath: [
`circle(0 at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`
]
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)'
}
);
});
}Transición de Galería con Imágenes
<!-- Lista de thumbnails -->
<div class="gallery">
<img src="img1.jpg" onclick="openLightbox(1)"
style="view-transition-name: gallery-img-1;">
<img src="img2.jpg" onclick="openLightbox(2)"
style="view-transition-name: gallery-img-2;">
</div>
<!-- Lightbox -->
<div id="lightbox" style="display: none;">
<img id="lightbox-img" style="view-transition-name: gallery-img-1;">
<button onclick="closeLightbox()">X</button>
</div><!-- Lista de thumbnails -->
<div class="gallery">
<img src="img1.jpg" onclick="openLightbox(1)"
style="view-transition-name: gallery-img-1;">
<img src="img2.jpg" onclick="openLightbox(2)"
style="view-transition-name: gallery-img-2;">
</div>
<!-- Lightbox -->
<div id="lightbox" style="display: none;">
<img id="lightbox-img" style="view-transition-name: gallery-img-1;">
<button onclick="closeLightbox()">X</button>
</div>function openLightbox(id) {
const lightbox = document.getElementById('lightbox');
const img = document.getElementById('lightbox-img');
if (!document.startViewTransition) {
// Fallback sin animación
img.src = `img${id}.jpg`;
img.style.viewTransitionName = `gallery-img-${id}`;
lightbox.style.display = 'flex';
return;
}
document.startViewTransition(() => {
img.src = `img${id}.jpg`;
img.style.viewTransitionName = `gallery-img-${id}`;
lightbox.style.display = 'flex';
});
}
function closeLightbox() {
const lightbox = document.getElementById('lightbox');
if (!document.startViewTransition) {
lightbox.style.display = 'none';
return;
}
document.startViewTransition(() => {
lightbox.style.display = 'none';
});
}function openLightbox(id) {
const lightbox = document.getElementById('lightbox');
const img = document.getElementById('lightbox-img');
if (!document.startViewTransition) {
// Fallback sin animación
img.src = `img${id}.jpg`;
img.style.viewTransitionName = `gallery-img-${id}`;
lightbox.style.display = 'flex';
return;
}
document.startViewTransition(() => {
img.src = `img${id}.jpg`;
img.style.viewTransitionName = `gallery-img-${id}`;
lightbox.style.display = 'flex';
});
}
function closeLightbox() {
const lightbox = document.getElementById('lightbox');
if (!document.startViewTransition) {
lightbox.style.display = 'none';
return;
}
document.startViewTransition(() => {
lightbox.style.display = 'none';
});
}#lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
}
#lightbox img {
max-width: 90%;
max-height: 90%;
contain: layout;
}
.gallery img {
width: 200px;
height: 150px;
object-fit: cover;
cursor: pointer;
contain: layout;
}#lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
}
#lightbox img {
max-width: 90%;
max-height: 90%;
contain: layout;
}
.gallery img {
width: 200px;
height: 150px;
object-fit: cover;
cursor: pointer;
contain: layout;
}Cambio de Tema
const themeToggle = document.getElementById('theme-toggle');
themeToggle.addEventListener('click', () => {
if (!document.startViewTransition) {
// Fallback
document.documentElement.classList.toggle('dark-theme');
return;
}
document.startViewTransition(() => {
document.documentElement.classList.toggle('dark-theme');
});
});const themeToggle = document.getElementById('theme-toggle');
themeToggle.addEventListener('click', () => {
if (!document.startViewTransition) {
// Fallback
document.documentElement.classList.toggle('dark-theme');
return;
}
document.startViewTransition(() => {
document.documentElement.classList.toggle('dark-theme');
});
});:root {
--bg-color: white;
--text-color: black;
view-transition-name: root-theme;
}
:root.dark-theme {
--bg-color: #1a1a1a;
--text-color: white;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: none; /* View Transitions maneja la transición */
}
/* Animación circular para cambio de tema */
::view-transition-old(root-theme),
::view-transition-new(root-theme) {
animation-duration: 0.5s;
mix-blend-mode: normal;
}:root {
--bg-color: white;
--text-color: black;
view-transition-name: root-theme;
}
:root.dark-theme {
--bg-color: #1a1a1a;
--text-color: white;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: none; /* View Transitions maneja la transición */
}
/* Animación circular para cambio de tema */
::view-transition-old(root-theme),
::view-transition-new(root-theme) {
animation-duration: 0.5s;
mix-blend-mode: normal;
}View Transition Types
Los tipos permiten aplicar diferentes estilos de transición según el contexto:
// JavaScript
document.startViewTransition({
update: updateDOM,
types: ['slide', 'user-initiated']
});// JavaScript
document.startViewTransition({
update: updateDOM,
types: ['slide', 'user-initiated']
});/* CSS - aplicar estilos solo cuando está activo un tipo */
html:active-view-transition-type(slide) {
&::view-transition-old(root) {
animation-name: slide-out;
}
&::view-transition-new(root) {
animation-name: slide-in;
}
}
html:active-view-transition-type(fade) {
&::view-transition-old(root) {
animation-name: fade-out;
}
&::view-transition-new(root) {
animation-name: fade-in;
}
}/* CSS - aplicar estilos solo cuando está activo un tipo */
html:active-view-transition-type(slide) {
&::view-transition-old(root) {
animation-name: slide-out;
}
&::view-transition-new(root) {
animation-name: slide-in;
}
}
html:active-view-transition-type(fade) {
&::view-transition-old(root) {
animation-name: fade-out;
}
&::view-transition-new(root) {
animation-name: fade-in;
}
}Casos de Uso Prácticos
1. E-commerce: Agregar al Carrito
function addToCart(productId) {
const product = document.querySelector(`[data-product="${productId}"]`);
const cart = document.getElementById('cart-icon');
// Nombre único para animar este producto específico
product.style.viewTransitionName = 'flying-product';
cart.style.viewTransitionName = 'cart-destination';
if (!document.startViewTransition) {
updateCartCount();
product.style.viewTransitionName = '';
cart.style.viewTransitionName = '';
return;
}
const transition = document.startViewTransition(() => {
updateCartCount();
// Limpiar nombres después de transición
product.style.viewTransitionName = '';
cart.style.viewTransitionName = '';
});
}function addToCart(productId) {
const product = document.querySelector(`[data-product="${productId}"]`);
const cart = document.getElementById('cart-icon');
// Nombre único para animar este producto específico
product.style.viewTransitionName = 'flying-product';
cart.style.viewTransitionName = 'cart-destination';
if (!document.startViewTransition) {
updateCartCount();
product.style.viewTransitionName = '';
cart.style.viewTransitionName = '';
return;
}
const transition = document.startViewTransition(() => {
updateCartCount();
// Limpiar nombres después de transición
product.style.viewTransitionName = '';
cart.style.viewTransitionName = '';
});
}::view-transition-old(flying-product) {
animation: fly-to-cart 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fly-to-cart {
to {
transform: scale(0.2) translateY(-500px);
opacity: 0;
}
}::view-transition-old(flying-product) {
animation: fly-to-cart 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fly-to-cart {
to {
transform: scale(0.2) translateY(-500px);
opacity: 0;
}
}2. Navegación con Stack (iOS-style)
<!-- page1.html -->
<a href="page2.html" onclick="setTransitionType('forward')">
Ver detalles ->
</a>
<!-- page2.html -->
<a href="page1.html" onclick="setTransitionType('back')">
<- Volver
</a><!-- page1.html -->
<a href="page2.html" onclick="setTransitionType('forward')">
Ver detalles ->
</a>
<!-- page2.html -->
<a href="page1.html" onclick="setTransitionType('back')">
<- Volver
</a>function setTransitionType(type) {
sessionStorage.setItem('nav-direction', type);
}
// En cada página
window.addEventListener('pagereveal', (e) => {
const direction = sessionStorage.getItem('nav-direction') || 'forward';
if (e.viewTransition) {
e.viewTransition.types.add(direction);
}
sessionStorage.removeItem('nav-direction');
});function setTransitionType(type) {
sessionStorage.setItem('nav-direction', type);
}
// En cada página
window.addEventListener('pagereveal', (e) => {
const direction = sessionStorage.getItem('nav-direction') || 'forward';
if (e.viewTransition) {
e.viewTransition.types.add(direction);
}
sessionStorage.removeItem('nav-direction');
});/* Stack push (forward) */
:root:active-view-transition-type(forward) {
&::view-transition-old(root) {
animation: push-out 0.3s ease-out;
}
&::view-transition-new(root) {
animation: push-in 0.3s ease-in;
}
}
/* Stack pop (back) */
:root:active-view-transition-type(back) {
&::view-transition-old(root) {
animation: pop-out 0.3s ease-out;
}
&::view-transition-new(root) {
animation: pop-in 0.3s ease-in;
}
}
@keyframes push-out {
to {
transform: translateX(-30%) scale(0.95);
filter: brightness(0.7);
}
}
@keyframes push-in {
from { transform: translateX(100%); }
}
@keyframes pop-out {
to { transform: translateX(100%); }
}
@keyframes pop-in {
from {
transform: translateX(-30%) scale(0.95);
filter: brightness(0.7);
}
}/* Stack push (forward) */
:root:active-view-transition-type(forward) {
&::view-transition-old(root) {
animation: push-out 0.3s ease-out;
}
&::view-transition-new(root) {
animation: push-in 0.3s ease-in;
}
}
/* Stack pop (back) */
:root:active-view-transition-type(back) {
&::view-transition-old(root) {
animation: pop-out 0.3s ease-out;
}
&::view-transition-new(root) {
animation: pop-in 0.3s ease-in;
}
}
@keyframes push-out {
to {
transform: translateX(-30%) scale(0.95);
filter: brightness(0.7);
}
}
@keyframes push-in {
from { transform: translateX(100%); }
}
@keyframes pop-out {
to { transform: translateX(100%); }
}
@keyframes pop-in {
from {
transform: translateX(-30%) scale(0.95);
filter: brightness(0.7);
}
}3. Filtrado de Productos
const products = document.querySelectorAll('.product');
function filterProducts(category) {
// Asignar view-transition-name a cada producto
products.forEach((product, index) => {
product.style.viewTransitionName = `product-${index}`;
});
if (!document.startViewTransition) {
applyFilter(category);
return;
}
document.startViewTransition(() => {
applyFilter(category);
});
}
function applyFilter(category) {
products.forEach(product => {
if (category === 'all' || product.dataset.category === category) {
product.style.display = 'block';
} else {
product.style.display = 'none';
}
});
}const products = document.querySelectorAll('.product');
function filterProducts(category) {
// Asignar view-transition-name a cada producto
products.forEach((product, index) => {
product.style.viewTransitionName = `product-${index}`;
});
if (!document.startViewTransition) {
applyFilter(category);
return;
}
document.startViewTransition(() => {
applyFilter(category);
});
}
function applyFilter(category) {
products.forEach(product => {
if (category === 'all' || product.dataset.category === category) {
product.style.display = 'block';
} else {
product.style.display = 'none';
}
});
}Rendimiento y Mejores Prácticas
1. Contain para Mejor Rendimiento
/* Siempre usar contain en elementos con view-transition-name */
.transitionable-element {
view-transition-name: unique-name;
contain: layout; /* O paint, o layout paint */
}/* Siempre usar contain en elementos con view-transition-name */
.transitionable-element {
view-transition-name: unique-name;
contain: layout; /* O paint, o layout paint */
}2. Reducir Elementos Transitados
// MAL: Transicionar toda la página siempre
document.startViewTransition(() => {
// Actualización masiva del DOM
});
// BIEN: Transicionar solo lo necesario
document.startViewTransition(() => {
// Actualizar solo sección específica
document.getElementById('dynamic-section').innerHTML = newContent;
});// MAL: Transicionar toda la página siempre
document.startViewTransition(() => {
// Actualización masiva del DOM
});
// BIEN: Transicionar solo lo necesario
document.startViewTransition(() => {
// Actualizar solo sección específica
document.getElementById('dynamic-section').innerHTML = newContent;
});3. Skip Transitions Cuando Necesario
const transition = document.startViewTransition(() => {
updateDOM();
});
// Skip si el usuario quiere animaciones reducidas
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
transition.skipTransition();
}const transition = document.startViewTransition(() => {
updateDOM();
});
// Skip si el usuario quiere animaciones reducidas
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
transition.skipTransition();
}4. Preload para Cross-Document
<!-- Usar Speculation Rules para precargar siguiente página -->
<script type="speculationrules">
{
"prerender": [
{
"urls": ["/product-detail.html"],
"where": {
"href_matches": "/product-*"
}
}
]
}
</script><!-- Usar Speculation Rules para precargar siguiente página -->
<script type="speculationrules">
{
"prerender": [
{
"urls": ["/product-detail.html"],
"where": {
"href_matches": "/product-*"
}
}
]
}
</script>5. Evitar Nombres Duplicados
// MAL: Nombres duplicados causan que la transición falle
element1.style.viewTransitionName = 'my-element';
element2.style.viewTransitionName = 'my-element'; // ERROR
// BIEN: Nombres únicos
element1.style.viewTransitionName = 'element-1';
element2.style.viewTransitionName = 'element-2';
// MEJOR: Usar match-element para listas
.list-item {
view-transition-name: match-element;
}// MAL: Nombres duplicados causan que la transición falle
element1.style.viewTransitionName = 'my-element';
element2.style.viewTransitionName = 'my-element'; // ERROR
// BIEN: Nombres únicos
element1.style.viewTransitionName = 'element-1';
element2.style.viewTransitionName = 'element-2';
// MEJOR: Usar match-element para listas
.list-item {
view-transition-name: match-element;
}Compatibilidad y Fallbacks
Feature Detection
function transitionHelper({ update, types = [] }) {
// No soportado: ejecutar directamente
if (!document.startViewTransition) {
update();
return;
}
// Soportado: usar transición
if (types.length === 0) {
return document.startViewTransition(update);
}
// Con tipos (si están soportados)
return document.startViewTransition({
update,
types
});
}
// Uso
transitionHelper({
update: () => {
// Tu actualización del DOM
},
types: ['slide', 'forward']
});function transitionHelper({ update, types = [] }) {
// No soportado: ejecutar directamente
if (!document.startViewTransition) {
update();
return;
}
// Soportado: usar transición
if (types.length === 0) {
return document.startViewTransition(update);
}
// Con tipos (si están soportados)
return document.startViewTransition({
update,
types
});
}
// Uso
transitionHelper({
update: () => {
// Tu actualización del DOM
},
types: ['slide', 'forward']
});Fallback para Navegadores Antiguos
/* Transiciones CSS tradicionales como fallback */
@supports not (view-transition-name: none) {
.element {
transition: all 0.3s ease;
}
}
/* View Transitions cuando está soportado */
@supports (view-transition-name: none) {
.element {
view-transition-name: my-element;
contain: layout;
}
}/* Transiciones CSS tradicionales como fallback */
@supports not (view-transition-name: none) {
.element {
transition: all 0.3s ease;
}
}
/* View Transitions cuando está soportado */
@supports (view-transition-name: none) {
.element {
view-transition-name: my-element;
contain: layout;
}
}Progressive Enhancement
// Función helper completa
class ViewTransitionHelper {
static isSupported() {
return 'startViewTransition' in document;
}
static transition(updateCallback, options = {}) {
if (!this.isSupported()) {
updateCallback();
return Promise.resolve();
}
const { types = [], skipMotionCheck = false } = options;
// Respetar preferencia de motion reducido
if (!skipMotionCheck &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateCallback();
return Promise.resolve();
}
const transitionOptions = types.length > 0
? { update: updateCallback, types }
: updateCallback;
const transition = document.startViewTransition(transitionOptions);
return transition.finished;
}
}
// Uso
ViewTransitionHelper.transition(
() => updateDOM(),
{ types: ['slide'] }
).then(() => {
console.log('Transición completa');
});// Función helper completa
class ViewTransitionHelper {
static isSupported() {
return 'startViewTransition' in document;
}
static transition(updateCallback, options = {}) {
if (!this.isSupported()) {
updateCallback();
return Promise.resolve();
}
const { types = [], skipMotionCheck = false } = options;
// Respetar preferencia de motion reducido
if (!skipMotionCheck &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateCallback();
return Promise.resolve();
}
const transitionOptions = types.length > 0
? { update: updateCallback, types }
: updateCallback;
const transition = document.startViewTransition(transitionOptions);
return transition.finished;
}
}
// Uso
ViewTransitionHelper.transition(
() => updateDOM(),
{ types: ['slide'] }
).then(() => {
console.log('Transición completa');
});Herramientas y Recursos
DevTools
Chrome DevTools soporta debugging de View Transitions:
- Abre DevTools -> Performance
- Graba una transición
- Ve frames individuales de la animación
- Inspecciona pseudo-elementos generados
Bibliotecas y Frameworks
- Astro: Soporte nativo con
<ViewTransitions /> - SvelteKit: Hooks para view transitions
- Nuxt: Módulo community
nuxt-view-transitions - Turn.js: Librería para Turbo con view transitions
Recursos de Aprendizaje
- MDN: Documentación completa y actualizada
- Chrome for Developers: Artículos y demos
- view-transitions.netlify.app: Colección de demos
- Interop 2025: Especificación y estado de implementación
Conclusión
La View Transitions API es una adición poderosa a la plataforma web que permite crear experiencias de usuario fluidas y atractivas sin frameworks pesados. En 2025, con soporte amplio en navegadores modernos, es el momento perfecto para implementar transiciones de vista en tus proyectos.
Puntos Clave
Same-document: Para SPAs y actualizaciones dinámicas
Cross-document: Para MPAs con transiciones seamless
Personalizable: CSS completo para animaciones custom
Performante: Transiciones eficientes manejadas por el navegador
Accesible: Maneja automáticamente problemas de accesibilidad
Progressive Enhancement: Fácil implementar fallbacks
Cuándo Usar View Transitions
** Ideal para:**
- Navegación entre páginas en sitios de contenido
- Galerías de imágenes y lightboxes
- Listas filtradas/ordenadas
- Cambios de tema
- Carritos de compra con feedback visual
- Transiciones entre estados de UI
** Considerar alternativas para:**
- Animaciones muy complejas (usar WAAPI directamente)
- Sitios con mucho JavaScript legacy
- Cuando necesitas soporte de navegadores muy antiguos
Con View Transitions API, puedes crear experiencias web modernas que rivalizan con aplicaciones nativas, todo usando estándares web nativos.