You have implemented the View Transitions API in a Next.js or React application. The navigation works, but the visual morphing feels "off." Images stretch unpleasantly during the transition (aspect-ratio distortion), or the animation snaps abruptly because multiple elements in a list mistakenly claimed the same view-transition-name.
These artifacts destroy the illusion of continuity. This post details the root cause of these rendering glitches and provides a rigorous solution using React Context for state scoping and advanced CSS pseudo-element manipulation.
The Root Cause: Rasterization and Namespace Collisions
There are two distinct technical failures occurring when view transitions glitch:
The Rasterization Trap (Aspect Ratio Mismatch): When the browser captures a snapshot of an element (e.g., a thumbnail
1:1) to morph it into a new element (e.g., a hero image16:9), it creates a::view-transition-group. By default, the browser simply scales the width and height of the rasterized screenshot from the "old" size to the "new" size. Since this is a transform on a raster image (not a layout recalculation), the content inside appears squashed or stretched during the tween. The standardobject-fit: coverproperty does not apply to the snapshot group during the transition.Namespace Collisions: The
view-transition-namemust be unique on the page. In a list view (e.g., an e-commerce grid), assigningview-transition-name: product-imageto every item causes the transition to abort or fail silently because the browser cannot determine which element corresponds to the destination. Conversely, assigning unique IDs (product-image-1,product-image-2) to every item creates a heavy memory footprint and rendering cost for the browser's composition layer.
The Fix
To solve this, we must:
- Scoped Naming: Only assign a
view-transition-nameto the specific element being interacted with. - Uncouple Geometry from Content: Use CSS to force the transition snapshots to maintain aspect ratio logic during the animation frames.
1. The React Logic: Scoped Transition Context
We need a mechanism to tag the clicked element just before the route change and clean it up afterward. This ensures only one pair of elements (source and destination) ever shares a name.
Create a ViewTransitionContext to manage this ephemeral state.
// src/context/ViewTransitionContext.tsx
'use client';
import React, { createContext, useContext, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
type TransitionContextType = {
activeSlug: string | null;
triggerTransition: (slug: string, href: string) => void;
};
const ViewTransitionContext = createContext<TransitionContextType | null>(null);
export function ViewTransitionProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [activeSlug, setActiveSlug] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const triggerTransition = (slug: string, href: string) => {
// 1. Tag the element
setActiveSlug(slug);
// 2. Check for browser support
if (!document.startViewTransition) {
router.push(href);
return;
}
// 3. Execute the transition
document.startViewTransition(() => {
startTransition(() => {
router.push(href);
// Note: In a real Next.js app, we rely on the router to update the DOM.
// We do NOT clear the slug here immediately; we need it to persist
// until the next page mounts to complete the match.
});
});
};
return (
<ViewTransitionContext.Provider value={{ activeSlug, triggerTransition }}>
{children}
</ViewTransitionContext.Provider>
);
}
export const useViewTransition = () => {
const context = useContext(ViewTransitionContext);
if (!context) throw new Error('useViewTransition must be used within Provider');
return context;
};
2. The Component Implementation
Apply the dynamic naming. We assume a shared ProductImage component used in both the Grid (source) and the Detail (destination) pages.
// src/components/ProductCard.tsx
'use client';
import { useViewTransition } from '@/context/ViewTransitionContext';
interface ProductCardProps {
slug: string;
imageUrl: string;
}
export default function ProductCard({ slug, imageUrl }: ProductCardProps) {
const { activeSlug, triggerTransition } = useViewTransition();
// Only apply the name if this specific card was clicked
const transitionName = activeSlug === slug ? 'hero-image-morph' : 'none';
return (
<div
className="card"
onClick={() => triggerTransition(slug, `/products/${slug}`)}
>
<img
src={imageUrl}
alt="Product"
style={{ viewTransitionName: transitionName }}
className="w-full aspect-square object-cover"
/>
<h3>View Details</h3>
</div>
);
}
// src/app/products/[slug]/page.tsx
// The destination page
export default function ProductDetail({ params }: { params: { slug: string } }) {
// On the destination, we ALWAYS want the name applied to catch the incoming transition.
// The CSS name must match the one set in the Context.
return (
<main className="p-10">
<div className="max-w-4xl mx-auto">
<img
src={`/images/${params.slug}.jpg`}
alt="Hero"
style={{ viewTransitionName: 'hero-image-morph' }}
className="w-full aspect-video object-cover"
/>
<h1>Product {params.slug}</h1>
</div>
</main>
);
}
3. The CSS Fix: Preventing Aspect-Ratio Distortion
This is the critical step for fixing visual artifacts. We must override how the browser blends the old image into the new image.
Instead of stretching the rasterized snapshots, we force them to fill the expanding container while maintaining their internal object-fit logic.
/* src/app/globals.css */
/* Target the specific transition name we defined in React */
::view-transition-group(hero-image-morph) {
/* Ensure the container animates smoothly without clipping prematurely */
overflow: hidden;
}
::view-transition-old(hero-image-morph),
::view-transition-new(hero-image-morph) {
/*
CRITICAL: Break the default aspect-ratio lock.
By default, the browser scales the raster.
We force the height/width to 100% of the *group* (which is animating),
and use object-fit to handle the content inside that changing box.
*/
height: 100%;
width: 100%;
object-fit: cover;
object-position: center;
/* Optional: Fixes some sub-pixel rendering or border-radius clipping issues */
transform-origin: center;
}
/*
Fix the "Cross-Fade" ghosting.
If the aspect ratios are very different, the default 'plus-lighter' blend
can look messy. Using standard mixing often looks cleaner for geometry morphs.
*/
::view-transition-old(hero-image-morph) {
animation: none; /* or custom keyframe */
opacity: 0; /* instant swap often looks better for resizing images than a fade */
}
::view-transition-new(hero-image-morph) {
animation: none;
}
Why This Works
Decoupling the Namespace
By storing activeSlug in a React Context and only applying viewTransitionName: 'hero-image-morph' to the clicked item, we ensure that:
- The browser's transition engine only tracks one heavy element pair.
- We avoid naming collisions on the listing page.
- We do not need to generate thousands of unique CSS rules (
item-1,item-2, etc.).
Solving the "Stretch"
Standard view transitions work by capturing a screenshot of the "old" state and the "new" state. The browser then transitions the width and height of the ::view-transition-group wrapper.
However, the ::view-transition-old and ::view-transition-new pseudo-elements (the screenshots) are children of that group. By default, they match the aspect ratio of their capture. When the group changes aspect ratio (1:1 to 16:9), the browser stretches the child snapshots to fit the group.
By applying height: 100%; width: 100%; object-fit: cover to the pseudo-elements, we tell the browser: "Don't treat this as a fixed-ratio sticker. Treat this as a dynamic texture that should cover the animating box." This mimics the behavior of a real DOM element responding to a window resize, resulting in a fluid, artifact-free morph.
Conclusion
The View Transitions API is powerful, but default browser behaviors regarding raster scaling can create uncouth visual results in professional applications. By manually managing the transition namespace via React state and overriding CSS pseudo-element sizing, you ensure your list-to-detail animations are performant and geometrically accurate.