React View Transitions
Implements smooth, performant page transitions and element morphing animations in React applications using the View Transition API. Covers same-document transitions for SPAs, cross-document transitions for MPA navigations, CSS animation customization with pseudo-elements, and progressive enhancement for browsers that do not yet support the API.
TL;DR Checklist
- Wrap route changes in
document.startViewTransition()to capture before/after states - Assign unique
view-transition-nameto each animated element — never reuse names - Customize transitions using
::view-transition-old()and::view-transition-new()pseudo-elements - Animate only
transformandopacityfor GPU-accelerated, jank-free animations - Check
@supports (view-transition-name: foo)before using the API — provide fallback navigation - Respect
prefers-reduced-motionby disabling or simplifying transitions - Clean up
view-transition-nameassignments when components unmount to prevent stale references
When to Use
Use this skill when:
- Implementing smooth page-to-page transitions in a React single-page application
- Adding element morphing animations (e.g., a list item expanding into a detail view)
- Creating shared-element transitions between routes (e.g., hero image scales from card to detail)
- Building gesture-driven navigation with animated transitions between views
- Enhancing perceived performance by replacing instant route changes with fluid animations
- Adding exit and entry animations for elements entering or leaving the DOM
When NOT to Use
Avoid this skill for:
- Applications that must support browsers without View Transition API (e.g., older Safari or Firefox ESR) — provide a seamless fallback
- Simple fade or slide transitions that can be implemented with CSS animations alone
- Complex 3D transitions or multi-step choreographed animations — keep it simple with transform/opacity
- Situations where animation length hurts perceived performance — keep transitions under 300ms
- Server-rendered multi-page applications using full page reloads — cross-document transitions have separate considerations
Core Workflow
Wrap Navigation in startViewTransition — Intercept route changes (using React Router, Next.js, or a custom router) and wrap the state update in
document.startViewTransition(). This captures a screenshot of the current state (old) and transitions to the new state. Checkpoint: Verify the callback passed tostartViewTransitionupdates the DOM synchronously — React 18+ concurrent mode requires wrapping state updates influshSync.Assign view-transition-name — Add a unique CSS
view-transition-nameto each element that should animate during the transition. Elements with the sameview-transition-namein both old and new states are paired for morphing. Checkpoint: Ensure everyview-transition-namevalue is unique across the entire page — duplicates cause the browser to ignore the transition.Customize Animations — Override the default cross-fade animation using
::view-transition-old()and::view-transition-new()pseudo-elements. Customizeanimation-name,animation-duration, andanimation-timing-functionto create slide, scale, or morph effects. Checkpoint: Test that both the old-state exit animation and new-state entry animation play correctly.Handle Cross-Document Transitions — For MPA navigations or full page loads, add the
@view-transitionCSS rule to opt-in specific page navigations. Cross-document transitions work across different HTML documents without JavaScript. Checkpoint: Verify the transition plays on navigation by clicking a link to another page on the same origin.Add Progressive Enhancement — Wrap View Transition API usage in a
@supports (view-transition-name: foo)CSS query and a runtime check withdocument.startViewTransitionin JavaScript. Provide a fallback that performs the navigation without animation for unsupported browsers. Checkpoint: Test navigation in a browser without View Transition support (e.g., Firefox) — the app must still function correctly.
Implementation Patterns
Pattern 1: React Router View Transition Wrapper
import { useCallback, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { useNavigate, type NavigateOptions } from "react-router-dom";
/**
* Hook that wraps navigation calls with View Transition API support.
* Falls back to regular navigation when the API is unavailable.
*/
function useViewTransitionNavigate() {
const navigate = useNavigate();
const transitionNavigate = useCallback(
(to: string, options?: NavigateOptions) => {
// Check for View Transition API support
if (!document.startViewTransition) {
navigate(to, options);
return;
}
// Wrap the navigation in a view transition
const transition = document.startViewTransition(() => {
flushSync(() => {
navigate(to, options);
});
});
// Track the transition for potential cancellation
return transition.finished.catch((error) => {
if (error.name !== "AbortError") {
console.error("View transition failed:", error);
}
});
},
[navigate]
);
return transitionNavigate;
}
// Usage in a navigation component
function PageLink({ to, children }: { to: string; children: ReactNode }) {
const navigateWithTransition = useViewTransitionNavigate();
return (
<a
href={to}
=> {
event.preventDefault();
navigateWithTransition(to);
}}
>
{children}
</a>
);
}
Pattern 2: Shared Element Morphing (BAD vs. GOOD)
/* ❌ BAD: Using view-transition-name on too many elements or with non-unique names */
.card-thumbnail {
view-transition-name: thumbnail; /* Not unique — will clash on lists */
}
/* ❌ BAD: Animating expensive properties */
::view-transition-old(root) {
animation: 300ms ease-out both fade-and-slide-3d;
}
@keyframes fade-and-slide-3d {
to {
transform: rotateY(90deg) translateX(100px); /* 3D transforms are expensive */
opacity: 0;
}
}
/* ✅ GOOD: Unique view-transition-name per element pair */
.card-article-42 .card-thumbnail {
view-transition-name: article-42-thumbnail; /* Unique per card/article */
}
.card-article-99 .card-thumbnail {
view-transition-name: article-99-thumbnail;
}
/* ✅ GOOD: Animating only transform and opacity for performance */
::view-transition-old(article-thumbnail) {
animation: 200ms ease-out both shrink-out;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-new(article-thumbnail) {
animation: 200ms ease-in both grow-in;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes shrink-out {
from { transform: scale(1); opacity: 1; }
to { transform: scale(0.8); opacity: 0; }
}
@keyframes grow-in {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
Pattern 3: Page Transition with Slide Animation
// components/ViewTransitionLayout.tsx
import { Outlet, useLocation } from "react-router-dom";
import { useEffect, useRef } from "react";
/**
* Layout component that assigns unique transition names per route
* to enable slide-in/out page transitions.
*/
export function ViewTransitionLayout() {
const location = useLocation();
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Assign a unique transition name based on the current route
const container = containerRef.current;
if (!container) return;
const routeName = location.pathname.replace(/\//g, "-") || "home";
container.style.viewTransitionName = `page-${routeName}`;
// Clean up on unmount to prevent stale view-transition-name references
return () => {
container.style.viewTransitionName = "";
};
}, [location.pathname]);
return (
<div ref={containerRef} className="page-container">
<Outlet />
</div>
);
}
/* styles/view-transitions.css */
/* Enable cross-document view transitions for MPA navigations */
@view-transition {
navigation: auto;
}
/* Customize the root (page-level) transition with a slide effect */
::view-transition-old(root) {
animation: 250ms ease-out both slide-out-left;
}
::view-transition-new(root) {
animation: 250ms ease-in both slide-in-right;
}
@keyframes slide-out-left {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-30px); opacity: 0; }
}
@keyframes slide-in-right {
from { transform: translateX(30px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Progressive enhancement for unsupported browsers */
@supports not (view-transition-name: root) {
/* No animation — browser navigates instantly */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.01ms !important;
}
}
Constraints
MUST DO
- Animate only
transformandopacityfor GPU-accelerated, main-thread-friendly animations - Check
@supports (view-transition-name: <value>)in CSS before relying on View Transition API features - Assign unique
view-transition-namevalues per element instance — never reuse names across different elements - Respect
prefers-reduced-motionby setting animation duration to0.01mswhen the user prefers reduced motion - Clean up
view-transition-namestyle properties when React components unmount to prevent memory leaks - Keep transition durations under 300ms for page transitions — longer animations hurt perceived performance
MUST NOT DO
- Animate
width,height,top,left,margin, orpaddingin view transitions — these trigger layout thrashing - Use the same
view-transition-namefor multiple elements on the same page — the browser ignores the name - Skip progressive enhancement — always provide a fallback for browsers without View Transition API support
- Apply view transitions to every navigation indiscriminately — use them selectively for meaningful transitions
- Ignore the
transition.finishedpromise rejection — handleAbortErrorgracefully - Rely on View Transition API for critical visual state changes that must be visible in all browsers
Related Skills
| Skill | Purpose |
|---|---|
css-architecture |
CSS organization patterns that complement view transition styling |
frontend-philosophy |
UI design principles for intentional, purposeful animation in interfaces |
design-systems |
Design system patterns for consistent transition behavior across components |
Live References
Authoritative documentation links for this skill's domain. The model follows markdown links at load time to resolve external references and inline content.