Desarrollo Web Tutoriales

Guía Completa de View Transitions API

Crea transiciones fluidas entre páginas sin frameworks

27 de diciembre de 2025 10 min de lectura

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

  1. ¿Qué es View Transitions API?
  2. Soporte de Navegadores
  3. Transiciones Same-Document (SPA)
  4. Transiciones Cross-Document (MPA)
  5. Personalización con CSS
  6. Transiciones Avanzadas
  7. View Transition Types
  8. Casos de Uso Prácticos
  9. Rendimiento y Mejores Prácticas
  10. 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:

  1. Capturar snapshots del estado actual de la página
  2. Actualizar el DOM (o navegar a nueva página)
  3. Animar automáticamente entre los dos estados
  4. 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)

plaintext
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

javascript
// 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

javascript
// 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

jsx
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

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:

css
/* 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)

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)

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)

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

css
/* 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:

plaintext
::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

html
<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>
css
/* 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:

css
/* 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:

css
/* 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

javascript
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

html
<!-- 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>
javascript
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';
  });
}
css
#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

javascript
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');
  });
});
css
: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
// JavaScript
document.startViewTransition({
  update: updateDOM,
  types: ['slide', 'user-initiated']
});
// JavaScript
document.startViewTransition({
  update: updateDOM,
  types: ['slide', 'user-initiated']
});
css
/* 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

javascript
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 = '';
  });
}
css
::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)

html
<!-- 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>
javascript
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');
});
css
/* 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

javascript
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

css
/* 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

javascript
//  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

javascript
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

html
<!-- 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

javascript
//  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

javascript
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

css
/* 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

javascript
// 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:

  1. Abre DevTools -> Performance
  2. Graba una transición
  3. Ve frames individuales de la animación
  4. 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.