View Transitions API Patterns
Quick Guide: Use the View Transitions API for native page/state transitions.
document.startViewTransition()for same-document,@view-transition { navigation: auto }for cross-document MPA. Always feature-detect before use and respectprefers-reduced-motion.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST feature-detect before using startViewTransition - it is NOT available in all browsers)
(You MUST respect prefers-reduced-motion by providing reduced or disabled animations)
(You MUST ensure view-transition-name values are unique - duplicate names break transitions)
(You MUST clean up dynamically assigned view-transition-name values after transitions complete)
(You MUST use named constants for all animation timing values - NO magic numbers)
</critical_requirements>
Auto-detection: View Transitions API, startViewTransition, view-transition-name, @view-transition, ::view-transition, pageswap, pagereveal, ViewTransition, view-transition-class
When to use:
- •Animating state changes in single-page applications
- •Creating smooth page-to-page transitions in multi-page applications
- •Implementing shared element (hero) animations between views
- •Providing visual continuity during navigation
- •Creating custom transition effects (slide, scale, circular reveal)
Key patterns covered:
- •Same-document transitions with startViewTransition()
- •Cross-document MPA transitions with @view-transition CSS
- •view-transition-name for shared element animations
- •Pseudo-element styling (::view-transition-old, ::view-transition-new)
- •Direction-aware transitions with :active-view-transition-type()
- •Feature detection and graceful fallbacks
- •prefers-reduced-motion accessibility patterns
When NOT to use:
- •Complex physics-based animations (use animation libraries)
- •Animations requiring precise timeline control
- •Browsers without View Transitions support (always provide fallback)
- •Simple hover/focus effects (use CSS transitions)
Detailed Resources:
- •For code examples, see examples/ folder
- •For decision frameworks and anti-patterns, see reference.md
<philosophy>
Philosophy
The View Transitions API provides a native browser mechanism for creating animated transitions between DOM states or pages. It captures "before" and "after" snapshots, overlays them as pseudo-elements, and animates between them.
Core principles:
- •Native over library - Browser-native transitions are more performant and require less JavaScript
- •Progressive enhancement - Always feature-detect and provide functional fallback
- •Snapshot-based - Old state is captured as a screenshot, new state as a live representation
- •CSS-driven - Customize animations through pseudo-element CSS, not JavaScript
- •Accessibility-first - Always respect prefers-reduced-motion user preferences
<patterns>
Core Patterns
Pattern 1: Feature Detection with Fallback
Always check for API support before using View Transitions.
Basic Feature Detection
const SUPPORTS_VIEW_TRANSITIONS =
typeof document !== "undefined" && "startViewTransition" in document;
function updateWithTransition(updateFn: () => void | Promise<void>): void {
if (!SUPPORTS_VIEW_TRANSITIONS) {
updateFn();
return;
}
document.startViewTransition(() => updateFn());
}
Why good: Prevents runtime errors in unsupported browsers, provides seamless fallback, named constant for reusability
// Bad Example - No feature detection document.startViewTransition(() => updateDOM()); // Crashes in Firefox < 144!
Why bad: Crashes in unsupported browsers, no fallback for users without support
Pattern 2: Same-Document (SPA) Transitions
Animate DOM state changes within a single page using startViewTransition().
Basic State Transition
const TRANSITION_DURATION_MS = 300;
type ViewTransitionCallback = () => void | Promise<void>;
async function transitionTo(updateFn: ViewTransitionCallback): Promise<void> {
if (!document.startViewTransition) {
await updateFn();
return;
}
const transition = document.startViewTransition(async () => {
await updateFn();
});
await transition.finished;
}
// Usage
function handleNavigation(page: string): void {
transitionTo(() => {
setCurrentPage(page);
});
}
Why good: Wraps feature detection, handles async updates, returns promise for chaining
ViewTransition Object Properties
interface ViewTransitionPromises {
ready: Promise<void>; // Pseudo-element tree created
updateCallbackDone: Promise<void>; // DOM update complete
finished: Promise<void>; // Animation finished
}
async function transitionWithCustomAnimation(
updateFn: () => void,
): Promise<void> {
if (!document.startViewTransition) {
updateFn();
return;
}
const transition = document.startViewTransition(updateFn);
// Wait for pseudo-elements to be ready
await transition.ready;
// Now safe to apply custom animations via Web Animations API
console.log("Pseudo-elements ready for custom animation");
await transition.finished;
console.log("Transition complete");
}
Why good: Shows the three promise stages for different timing needs
Pattern 3: Cross-Document (MPA) Transitions
Enable transitions between separate pages without JavaScript.
CSS Opt-In
/* styles.css - Include on BOTH pages */
@view-transition {
navigation: auto;
}
Why good: No JavaScript required, works for traverse/push/replace navigations
/* Bad Example - Obsolete meta tag syntax */
/* <meta name="view-transition" content="same-origin"> */
/* Good - Use CSS at-rule instead */
@view-transition {
navigation: auto;
}
Why bad: Meta tag syntax is obsolete, CSS at-rule is the current standard
Pattern 4: Shared Element Transitions
Create hero animations by giving matching elements the same view-transition-name.
CSS Shared Elements
/* On list page */
.product-thumbnail {
view-transition-name: product-hero;
}
/* On detail page */
.product-image {
view-transition-name: product-hero;
}
/* Customize the transition */
::view-transition-group(product-hero) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
Why good: Same name creates automatic shared element transition, custom timing via pseudo-element
Dynamic Name Assignment
function setTransitionNames(elements: Array<[HTMLElement, string]>): void {
elements.forEach(([el, name]) => {
el.style.viewTransitionName = name;
});
}
function clearTransitionNames(elements: HTMLElement[]): void {
elements.forEach((el) => {
el.style.viewTransitionName = "";
});
}
async function transitionWithSharedElement(
element: HTMLElement,
name: string,
updateFn: () => void,
): Promise<void> {
if (!document.startViewTransition) {
updateFn();
return;
}
element.style.viewTransitionName = name;
const transition = document.startViewTransition(updateFn);
await transition.finished;
// Clean up to prevent conflicts
element.style.viewTransitionName = "";
}
Why good: Dynamic assignment allows programmatic control, cleanup prevents name conflicts
Pattern 5: Custom CSS Animations
Override default cross-fade with custom animations via pseudo-elements.
Slide Transitions
/* Named constants as CSS custom properties */
:root {
--transition-duration: 300ms;
--transition-easing: ease-in-out;
}
@keyframes slide-out-left {
to {
transform: translateX(-100%);
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
}
::view-transition-old(root) {
animation: slide-out-left var(--transition-duration) var(--transition-easing);
}
::view-transition-new(root) {
animation: slide-in-right var(--transition-duration) var(--transition-easing);
}
Why good: CSS custom properties for timing constants, GPU-accelerated transforms
Scale and Fade
:root {
--scale-duration: 250ms;
--scale-hidden: 0.95;
}
@keyframes scale-down {
to {
transform: scale(var(--scale-hidden));
opacity: 0;
}
}
@keyframes scale-up {
from {
transform: scale(calc(1 / var(--scale-hidden)));
opacity: 0;
}
}
::view-transition-old(root) {
animation: scale-down var(--scale-duration) ease-in;
}
::view-transition-new(root) {
animation: scale-up var(--scale-duration) ease-out;
}
Why good: Scale and opacity are GPU-accelerated, CSS variables for consistent values
Pattern 6: Direction-Aware Transitions
Use different animations for forward vs backward navigation.
CSS with active-view-transition-type
/* Forward navigation */
html:active-view-transition-type(forwards) {
&::view-transition-old(content) {
animation-name: slide-out-left;
}
&::view-transition-new(content) {
animation-name: slide-in-right;
}
}
/* Backward navigation */
html:active-view-transition-type(backwards) {
&::view-transition-old(content) {
animation-name: slide-out-right;
}
&::view-transition-new(content) {
animation-name: slide-in-left;
}
}
@keyframes slide-out-right {
to {
transform: translateX(100%);
}
}
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
}
Setting Navigation Types
function setNavigationType(
transition: ViewTransition,
type: "forwards" | "backwards",
): void {
if ("types" in transition) {
(transition.types as Set<string>).add(type);
}
}
// For MPA, use pagereveal event
window.addEventListener("pagereveal", (e) => {
const event = e as PageRevealEvent;
if (event.viewTransition && "activation" in navigation) {
const navActivation = navigation.activation;
if (navActivation) {
const fromUrl = navActivation.from?.url;
const toUrl = navActivation.entry?.url;
// Determine direction based on URL structure
if (fromUrl && toUrl) {
const isForward = toUrl.includes("/detail");
setNavigationType(
event.viewTransition,
isForward ? "forwards" : "backwards",
);
}
}
}
});
Why good: Different animations for different navigation directions improve UX
Pattern 7: Accessibility - Reduced Motion
Always respect user preferences for reduced motion.
CSS Approach
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
/* Or disable entirely */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
/* Provide subtle alternative feedback */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root) {
animation: fade-out 150ms ease-out;
}
::view-transition-new(root) {
animation: fade-in 150ms ease-in;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
}
Why good: Respects user preferences, provides subtle alternative instead of nothing
JavaScript Approach
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
function shouldEnableTransitions(): boolean {
// Check reduced motion preference
if (window.matchMedia(REDUCED_MOTION_QUERY).matches) {
return false;
}
// Check for API support
return "startViewTransition" in document;
}
function transitionWithAccessibility(updateFn: () => void): void {
if (!shouldEnableTransitions()) {
updateFn();
return;
}
document.startViewTransition(updateFn);
}
// Hook for monitoring preference changes
function useReducedMotion(): boolean {
const query = window.matchMedia(REDUCED_MOTION_QUERY);
let prefersReduced = query.matches;
query.addEventListener("change", (e) => {
prefersReduced = e.matches;
});
return prefersReduced;
}
Why good: Checks preference before initiating transition, reactive to preference changes
Pattern 8: Circular Reveal Effect
Advanced custom animation using Web Animations API.
interface ClickPosition {
x: number;
y: number;
}
let lastClickPosition: ClickPosition = { x: 0, y: 0 };
document.addEventListener("click", (e: MouseEvent) => {
lastClickPosition = { x: e.clientX, y: e.clientY };
});
const REVEAL_DURATION_MS = 500;
const REVEAL_EASING = "ease-in-out";
async function circularRevealTransition(updateFn: () => void): Promise<void> {
if (!document.startViewTransition) {
updateFn();
return;
}
const { x, y } = lastClickPosition;
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
const transition = document.startViewTransition(updateFn);
await transition.ready;
document.documentElement.animate(
{
clipPath: [
`circle(0 at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: REVEAL_DURATION_MS,
easing: REVEAL_EASING,
pseudoElement: "::view-transition-new(root)",
},
);
}
Supporting CSS:
::view-transition-image-pair(root) {
isolation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
display: block;
}
Why good: Creates engaging circular reveal from click point, uses Web Animations API for precise control
</patterns><integration>
Integration Guide
View Transitions is a browser-native API. It works with any JavaScript approach and styling solution.
Works with:
- •Any DOM manipulation: Updates via direct DOM, signals, or virtual DOM all work
- •Any routing solution: Wrap route changes in startViewTransition()
- •Any styling approach: Customize via CSS pseudo-elements
Key integration points:
- •Call
document.startViewTransition()before DOM updates - •Use CSS
@view-transitionfor cross-document navigation - •Set
view-transition-namevia CSS or JavaScript for shared elements
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST feature-detect before using startViewTransition - it is NOT available in all browsers)
(You MUST respect prefers-reduced-motion by providing reduced or disabled animations)
(You MUST ensure view-transition-name values are unique - duplicate names break transitions)
(You MUST clean up dynamically assigned view-transition-name values after transitions complete)
(You MUST use named constants for all animation timing values - NO magic numbers)
Failure to follow these rules will break transitions in unsupported browsers and create inaccessible experiences.
</critical_reminders>