View Transitions API: Animaciones nativas sin JavaScript
View Transitions API: Animaciones nativas sin JavaScript
La View Transitions API está revolucionando cómo creamos transiciones entre páginas en la web. Por primera vez, podemos lograr animaciones fluidas y nativas entre navegaciones sin depender de librerías JavaScript pesadas o frameworks complejos. Esta API, que ya es estándar en navegadores modernos, permite crear experiencias similares a aplicaciones nativas con mínimo esfuerzo.
En esta guía completa, aprenderás a implementar View Transitions tanto en Astro como en Next.js, con ejemplos prácticos y patrones de diseño que puedes usar hoy mismo.
¿Qué es la View Transitions API?
La View Transitions API es una especificación del navegador que permite crear transiciones suaves entre diferentes estados del DOM. A diferencia de las soluciones tradicionales que requieren JavaScript para coordinar animaciones, esta API funciona de forma nativa en el navegador.
Características principales:
- Transiciones automáticas entre estados del DOM
- Sin necesidad de JavaScript para las animaciones básicas
- Soporte para animaciones de entrada, salida y morph
- Compatible con navegación del navegador (back/forward)
- Performance nativa del navegador
Soporte de navegadores (Diciembre 2024)
- ✅ Chrome/Edge 111+
- ✅ Opera 97+
- ✅ Safari 18+ (iOS y macOS)
- 🚧 Firefox: En desarrollo (disponible tras flag)
Para navegadores sin soporte, las transiciones simplemente no ocurren y la navegación funciona normalmente.
Conceptos fundamentales
1. View Transition Names
Cada elemento que quieres animar necesita un view-transition-name único:
.hero-image {
view-transition-name: hero;
}
.page-title {
view-transition-name: title;
}
Regla crítica: Los nombres deben ser únicos en la página. Si dos elementos tienen el mismo nombre, la transición fallará.
2. Estados: Old y New
Durante una transición, el navegador crea dos capturas:
- Old: El estado antes de la navegación
- New: El estado después de la navegación
El navegador automáticamente anima entre estos estados.
3. Pseudo-elementos generados
Durante la transición, el navegador crea pseudo-elementos que puedes estilizar:
::view-transition-old(hero) {
/* Estilos para el estado antiguo */
}
::view-transition-new(hero) {
/* Estilos para el estado nuevo */
}
::view-transition-group(hero) {
/* Estilos para el contenedor de ambos */
}
Implementación en Astro
Nota Importante
Astro tiene soporte nativo para View Transitions desde la versión 3.0, haciendo la implementación extremadamente sencilla.
Setup básico
---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
¡Eso es todo! Con solo agregar <ViewTransitions />, todas tus navegaciones ahora tienen un fade suave.
Transiciones personalizadas
1. Fade personalizado
---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout>
<main transition:animate="fade">
<h1>Página Principal</h1>
<p>Contenido con fade</p>
</main>
</BaseLayout>
<style>
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
::view-transition-old(root) {
animation: 300ms cubic-bezier(0.4, 0, 1, 1) both fadeOut;
}
::view-transition-new(root) {
animation: 300ms cubic-bezier(0, 0, 0.2, 1) both fadeIn;
}
</style>
2. Slide lateral
<style>
@keyframes slideFromRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slideToLeft {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
::view-transition-old(root) {
animation: 400ms cubic-bezier(0.4, 0, 0.2, 1) both slideToLeft;
}
::view-transition-new(root) {
animation: 400ms cubic-bezier(0.4, 0, 0.2, 1) both slideFromRight;
}
</style>
3. Scale y fade combinados
<style>
@keyframes scaleDown {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.9);
}
}
@keyframes scaleUp {
from {
opacity: 0;
transform: scale(1.1);
}
to {
opacity: 1;
transform: scale(1);
}
}
::view-transition-old(root) {
animation: 300ms ease-out both scaleDown;
}
::view-transition-new(root) {
animation: 300ms ease-out both scaleUp;
}
</style>
Transiciones específicas por elemento
El verdadero poder viene cuando animas elementos individuales entre páginas:
---
// src/pages/blog/index.astro
const posts = await getCollection('blog');
---
<BaseLayout>
<div class="grid">
{posts.map(post => (
<article>
<img
src={post.data.image}
alt={post.data.title}
transition:name={`post-image-${post.slug}`}
/>
<h2 transition:name={`post-title-${post.slug}`}>
{post.data.title}
</h2>
<a href={`/blog/${post.slug}`}>Leer más</a>
</article>
))}
</div>
</BaseLayout>
---
// src/pages/blog/[slug].astro
const { post } = Astro.props;
---
<BaseLayout>
<article>
<img
src={post.data.image}
alt={post.data.title}
transition:name={`post-image-${post.slug}`}
/>
<h1 transition:name={`post-title-${post.slug}`}>
{post.data.title}
</h1>
<div set:html={post.body} />
</article>
</BaseLayout>
Resultado: La imagen y el título se "mueven" suavemente de la posición en el grid a su posición en la página de detalle.
Directives de transición en Astro
Astro proporciona varios directives útiles:
<!-- Animación predefinida -->
<div transition:animate="slide">Contenido</div>
<!-- Nombre personalizado para morph -->
<img transition:name="hero-image" src="..." />
<!-- Persistir elemento entre navegaciones -->
<video transition:persist src="..." />
<!-- Mantener el estado del elemento -->
<div transition:persist="player-state">
<AudioPlayer />
</div>
Implementación en Next.js
Next.js no tiene soporte nativo integrado, pero podemos usar la View Transitions API directamente con el App Router.
Setup con App Router
// app/template.tsx
'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
export default function Template({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
useEffect(() => {
// Verificar soporte
if (!document.startViewTransition) {
return
}
// El navegador ya maneja la transición
// Solo necesitamos preparar el DOM
}, [pathname])
return children
}
Hook personalizado para transiciones
// hooks/useViewTransition.ts
'use client'
import { useRouter } from 'next/navigation'
export function useViewTransition() {
const router = useRouter()
const transitionTo = (url: string) => {
if (!document.startViewTransition) {
router.push(url)
return
}
document.startViewTransition(() => {
router.push(url)
})
}
return { transitionTo }
}
Uso del hook
// components/BlogCard.tsx
'use client'
import { useViewTransition } from '@/hooks/useViewTransition'
export default function BlogCard({ post }: { post: Post }) {
const { transitionTo } = useViewTransition()
return (
<article onClick={() => transitionTo(`/blog/${post.slug}`)}>
<img
src={post.image}
alt={post.title}
style={{ viewTransitionName: `post-image-${post.slug}` }}
/>
<h2 style={{ viewTransitionName: `post-title-${post.slug}` }}>
{post.title}
</h2>
</article>
)
}
Componente wrapper reutilizable
// components/ViewTransition.tsx
'use client'
import { useEffect, useRef } from 'react'
interface ViewTransitionProps {
name: string
children: React.ReactNode
className?: string
}
export default function ViewTransition({
name,
children,
className
}: ViewTransitionProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) {
ref.current.style.viewTransitionName = name
}
}, [name])
return (
<div ref={ref} className={className}>
{children}
</div>
)
}
// Uso:
<ViewTransition name="hero-image">
<img src={heroImage} alt="Hero" />
</ViewTransition>
Estilos globales en Next.js
/* app/globals.css */
/* Fade básico */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Transiciones específicas */
::view-transition-old(hero-image) {
animation: 500ms ease-out both scaleDown;
}
::view-transition-new(hero-image) {
animation: 500ms ease-out both scaleUp;
}
@keyframes scaleDown {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes scaleUp {
from {
opacity: 0;
transform: scale(1.05);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Transición de título */
::view-transition-group(title) {
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(title) {
animation: 400ms ease-out both fadeOut;
}
::view-transition-new(title) {
animation: 400ms ease-out 100ms both fadeIn;
}
@keyframes fadeOut {
to { opacity: 0; }
}
@keyframes fadeIn {
from { opacity: 0; }
}
Patrones avanzados
1. Transiciones condicionales según dirección
'use client'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
export default function DirectionalTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const [direction, setDirection] = useState<'forward' | 'back'>('forward')
useEffect(() => {
const handlePopState = () => {
setDirection('back')
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
return (
<div data-direction={direction}>
{children}
</div>
)
}
/* Estilos basados en dirección */
[data-direction="forward"] ::view-transition-old(root) {
animation: slideOutLeft 300ms ease-out;
}
[data-direction="forward"] ::view-transition-new(root) {
animation: slideInRight 300ms ease-out;
}
[data-direction="back"] ::view-transition-old(root) {
animation: slideOutRight 300ms ease-out;
}
[data-direction="back"] ::view-transition-new(root) {
animation: slideInLeft 300ms ease-out;
}
@keyframes slideOutLeft {
to { transform: translateX(-100%); }
}
@keyframes slideInRight {
from { transform: translateX(100%); }
}
@keyframes slideOutRight {
to { transform: translateX(100%); }
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
}
2. Transiciones basadas en tipo de página
// app/layout.tsx
'use client'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
useEffect(() => {
// Asignar clase según el tipo de página
const pageType = pathname.startsWith('/blog') ? 'blog'
: pathname.startsWith('/shop') ? 'shop'
: 'default'
document.body.dataset.pageType = pageType
}, [pathname])
return (
<html>
<body>{children}</body>
</html>
)
}
/* Diferentes transiciones por tipo de página */
[data-page-type="blog"] ::view-transition-old(root) {
animation: fadeOut 200ms ease-out;
}
[data-page-type="blog"] ::view-transition-new(root) {
animation: fadeIn 200ms ease-in;
}
[data-page-type="shop"] ::view-transition-old(root) {
animation: slideOutUp 300ms ease-out;
}
[data-page-type="shop"] ::view-transition-new(root) {
animation: slideInUp 300ms ease-out;
}
3. Hero image con morph effect
---
// src/pages/projects/index.astro
const projects = await getProjects();
---
<BaseLayout>
<div class="projects-grid">
{projects.map(project => (
<a href={`/projects/${project.slug}`} class="project-card">
<div
class="project-image"
transition:name={`project-${project.slug}`}
transition:animate="initial"
>
<img src={project.thumbnail} alt={project.title} />
</div>
<h3>{project.title}</h3>
</a>
))}
</div>
</BaseLayout>
<style>
.project-card {
cursor: pointer;
transition: transform 0.2s;
}
.project-card:hover {
transform: translateY(-4px);
}
.project-image {
aspect-ratio: 16/9;
overflow: hidden;
border-radius: 8px;
}
</style>
---
// src/pages/projects/[slug].astro
---
<BaseLayout>
<article>
<div
class="hero-image"
transition:name={`project-${project.slug}`}
>
<img src={project.image} alt={project.title} />
</div>
<h1>{project.title}</h1>
<p>{project.description}</p>
</article>
</BaseLayout>
<style>
.hero-image {
width: 100%;
height: 400px;
margin-bottom: 2rem;
}
/* La transición ocurre automáticamente */
/* La imagen "crece" desde el thumbnail al hero */
</style>
4. Gallery con shared element
// components/Gallery.tsx
'use client'
import { useState } from 'react'
import ViewTransition from './ViewTransition'
export default function Gallery({ images }: { images: string[] }) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
return (
<>
<div className="grid grid-cols-3 gap-4">
{images.map((img, idx) => (
<ViewTransition key={idx} name={`gallery-${idx}`}>
<img
src={img}
alt={`Gallery ${idx}`}
onClick={() => setSelectedIndex(idx)}
className="cursor-pointer hover:opacity-80 transition"
/>
</ViewTransition>
))}
</div>
{selectedIndex !== null && (
<div
className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
onClick={() => setSelectedIndex(null)}
>
<ViewTransition name={`gallery-${selectedIndex}`}>
<img
src={images[selectedIndex]}
alt={`Gallery ${selectedIndex}`}
className="max-w-4xl max-h-screen"
/>
</ViewTransition>
</div>
)}
</>
)
}
Performance y optimización
1. Reducir tiempo de transición en navegación rápida
// hooks/useViewTransition.ts
export function useViewTransition() {
const router = useRouter()
const transitionTo = (url: string, options?: { skipTransition?: boolean }) => {
if (options?.skipTransition || !document.startViewTransition) {
router.push(url)
return
}
// Timeout para evitar transiciones lentas
const timeout = setTimeout(() => {
router.push(url)
}, 1000)
document.startViewTransition(() => {
clearTimeout(timeout)
router.push(url)
})
}
return { transitionTo }
}
2. Preload de imágenes para transiciones suaves
---
// Precargar imágenes de la siguiente página
const nextPageImages = await getNextPageImages();
---
<head>
{nextPageImages.map(img => (
<link rel="preload" as="image" href={img} />
))}
</head>
3. Detectar soporte y fallback
// utils/viewTransitions.ts
export function supportsViewTransitions(): boolean {
return typeof document !== 'undefined' &&
'startViewTransition' in document
}
export function withViewTransition(callback: () => void) {
if (!supportsViewTransitions()) {
callback()
return
}
document.startViewTransition(callback)
}
Casos de uso reales
E-commerce: Producto a detalle
---
// src/pages/shop/index.astro
const products = await getProducts();
---
<div class="products-grid">
{products.map(product => (
<a href={`/shop/${product.id}`} class="product-card">
<img
src={product.image}
alt={product.name}
transition:name={`product-${product.id}-image`}
class="product-image"
/>
<h3 transition:name={`product-${product.id}-title`}>
{product.name}
</h3>
<p transition:name={`product-${product.id}-price`} class="price">
${product.price}
</p>
</a>
))}
</div>
---
// src/pages/shop/[id].astro
---
<article class="product-detail">
<img
src={product.image}
alt={product.name}
transition:name={`product-${product.id}-image`}
class="hero-image"
/>
<div class="product-info">
<h1 transition:name={`product-${product.id}-title`}>
{product.name}
</h1>
<p transition:name={`product-${product.id}-price`} class="price-large">
${product.price}
</p>
<p>{product.description}</p>
<button>Añadir al carrito</button>
</div>
</article>
<style>
.product-card .product-image {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.product-detail .hero-image {
width: 50%;
max-height: 600px;
object-fit: contain;
}
/* Las transiciones son automáticas */
/* La imagen crece y se reposiciona suavemente */
</style>
Blog con navegación entre posts
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
const relatedPosts = await getRelatedPosts(post.id)
return (
<article>
<div style={{ viewTransitionName: `post-hero-${params.slug}` }}>
<img src={post.heroImage} alt={post.title} />
</div>
<h1 style={{ viewTransitionName: `post-title-${params.slug}` }}>
{post.title}
</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<nav className="related-posts">
<h2>Posts relacionados</h2>
{relatedPosts.map(related => (
<BlogCard key={related.id} post={related} />
))}
</nav>
</article>
)
}
Debugging y herramientas
Chrome DevTools
Chrome DevTools ahora incluye herramientas para debuggear View Transitions:
- Abre DevTools
- Ve a la pestaña "Performance"
- Activa "Enable advanced paint instrumentation"
- Navega y graba la transición
- Inspecciona los frames de la transición
Slow motion para desarrollo
/* Solo en desarrollo */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 3s !important;
}
Visualizar nombres de transición
// Agregar en development
if (process.env.NODE_ENV === 'development') {
const style = document.createElement('style')
style.textContent = `
::view-transition-group(*) {
outline: 2px solid red;
}
`
document.head.appendChild(style)
}
Checklist de implementación
- Verificar soporte del navegador y agregar fallback
- Definir nombres únicos para elementos que transicionan
- Mantener consistencia en nombres entre páginas
- Precargar imágenes críticas
- Optimizar duración de animaciones (200-400ms recomendado)
- Probar en conexiones lentas
- Implementar timeout para transiciones largas
- Verificar accesibilidad (respeta prefers-reduced-motion)
- Medir impacto en Core Web Vitals
Conclusión
La View Transitions API representa un salto cualitativo en cómo creamos experiencias web. Por primera vez, podemos lograr transiciones nativas de calidad sin JavaScript pesado, frameworks complejos, o hacks de CSS.
Tanto Astro como Next.js ofrecen excelentes opciones para implementar estas transiciones. Astro con su soporte integrado hace la implementación trivial, mientras que Next.js con un poco más de configuración ofrece control total sobre el comportamiento.
El futuro de las transiciones web está aquí, y es nativo del navegador. Empieza simple con un fade básico, luego experimenta con shared elements y morphing. Tus usuarios notarán inmediatamente la diferencia en la percepción de calidad de tu aplicación.
¿Necesitas ayuda con este tema?
No dudes en contactarme para una asesoría personalizada. ¡Estoy aquí para ayudarte a llevar tu proyecto al siguiente nivel! 🚀