Steve Kinney

View Transitions API for React

Your React app navigates instantly thanks to client-side routing. But instant isn’t always better. When a page changes abruptly, users lose context. They don’t see how the old page relates to the new one. They experience what designers call “cognitive load”—that jarring moment where their brain has to reorient itself.

Native mobile apps solved this years ago with smooth transitions that guide the eye from one state to the next. Now, the View Transitions API brings that same power to the web. Imagine product images that smoothly grow into detailed views. Navigation that slides naturally between pages. State changes that feel intentional, not jarring.

Let’s transform your React app’s navigation from functional to delightful, using the View Transitions API to create transitions that aren’t just smooth—they’re meaningful.

Understanding View Transitions API

The View Transitions API captures snapshots and animates between them:

interface ViewTransition {
  ready: Promise<void>; // Resolves when transition is ready
  finished: Promise<void>; // Resolves when transition completes
  updateCallbackDone: Promise<void>; // Resolves when DOM update is done
  skipTransition(): void; // Skip the transition
}

// Basic usage
if (document.startViewTransition) {
  document.startViewTransition(() => {
    // Update the DOM
    updateDOM();
  });
} else {
  // Fallback for browsers without support
  updateDOM();
}

React Integration Patterns

Basic React Router Integration

import { useNavigate, useLocation } from 'react-router-dom';

const useViewTransition = () => {
  const navigate = useNavigate();

  const navigateWithTransition = useCallback((to: string) => {
    if (!document.startViewTransition) {
      navigate(to);
      return;
    }

    document.startViewTransition(async () => {
      navigate(to);
      // Wait for React to render
      await new Promise(resolve => setTimeout(resolve, 0));
    });
  }, [navigate]);

  return { navigateWithTransition };
};

// Usage in component
const NavigationLink: React.FC<{ to: string; children: React.ReactNode }> = ({
  to,
  children
}) => {
  const { navigateWithTransition } = useViewTransition();

  return (
    <button onClick={() => navigateWithTransition(to)}>
      {children}
    </button>
  );
};

Advanced Transition Manager

class ViewTransitionManager {
  private currentTransition: ViewTransition | null = null;
  private transitionQueue: Array<() => void> = [];

  async startTransition(
    updateDOM: () => void | Promise<void>,
    options?: TransitionOptions,
  ): Promise<void> {
    // Skip if no support
    if (!document.startViewTransition) {
      await updateDOM();
      return;
    }

    // Cancel current transition if exists
    if (this.currentTransition) {
      this.currentTransition.skipTransition();
    }

    // Apply transition class names
    if (options?.className) {
      document.documentElement.classList.add(options.className);
    }

    try {
      this.currentTransition = document.startViewTransition(async () => {
        await updateDOM();
      });

      // Wait for transition to be ready
      await this.currentTransition.ready;

      // Apply custom animations if provided
      if (options?.onReady) {
        options.onReady(this.currentTransition);
      }

      // Wait for transition to complete
      await this.currentTransition.finished;
    } catch (error) {
      console.error('View transition failed:', error);
      // Ensure DOM is updated even if transition fails
      await updateDOM();
    } finally {
      // Cleanup
      if (options?.className) {
        document.documentElement.classList.remove(options.className);
      }
      this.currentTransition = null;

      // Process next transition in queue
      this.processQueue();
    }
  }

  private processQueue() {
    const next = this.transitionQueue.shift();
    if (next) {
      next();
    }
  }

  queueTransition(transition: () => void) {
    this.transitionQueue.push(transition);
    if (!this.currentTransition) {
      this.processQueue();
    }
  }
}

interface TransitionOptions {
  className?: string;
  onReady?: (transition: ViewTransition) => void;
}

// React hook for transition manager
const useTransitionManager = () => {
  const managerRef = useRef(new ViewTransitionManager());
  return managerRef.current;
};

Named View Transitions

Implementing Shared Element Transitions

// Mark elements for shared transitions
const SharedElement: React.FC<{
  id: string;
  children: React.ReactNode;
}> = ({ id, children }) => {
  return (
    <div
      style={{
        viewTransitionName: id
      } as React.CSSProperties}
    >
      {children}
    </div>
  );
};

// Product grid with shared elements
const ProductGrid: React.FC = () => {
  const products = useProducts();
  const { navigateWithTransition } = useViewTransition();

  return (
    <div className="grid">
      {products.map(product => (
        <div
          key={product.id}
          onClick={() => navigateWithTransition(`/product/${product.id}`)}
        >
          <SharedElement id={`product-image-${product.id}`}>
            <img src={product.image} alt={product.name} />
          </SharedElement>
          <SharedElement id={`product-title-${product.id}`}>
            <h3>{product.name}</h3>
          </SharedElement>
        </div>
      ))}
    </div>
  );
};

// Product detail with matching shared elements
const ProductDetail: React.FC<{ id: string }> = ({ id }) => {
  const product = useProduct(id);

  return (
    <div className="detail">
      <SharedElement id={`product-image-${id}`}>
        <img src={product.image} alt={product.name} />
      </SharedElement>
      <SharedElement id={`product-title-${id}`}>
        <h1>{product.name}</h1>
      </SharedElement>
      <p>{product.description}</p>
    </div>
  );
};

Dynamic View Transition Names

const useDynamicTransitionName = (baseName: string, isActive: boolean) => {
  const [transitionName, setTransitionName] = useState<string | undefined>();

  useEffect(() => {
    if (isActive) {
      setTransitionName(baseName);
    } else {
      // Clear after transition completes
      const timeout = setTimeout(() => {
        setTransitionName(undefined);
      }, 500); // Match transition duration

      return () => clearTimeout(timeout);
    }
  }, [baseName, isActive]);

  return transitionName;
};

// Usage for morphing elements
const MorphingCard: React.FC<{ id: string; expanded: boolean }> = ({
  id,
  expanded
}) => {
  const transitionName = useDynamicTransitionName(`card-${id}`, true);

  return (
    <div
      className={expanded ? 'card-expanded' : 'card-collapsed'}
      style={{ viewTransitionName: transitionName } as React.CSSProperties}
    >
      {/* Card content */}
    </div>
  );
};

Custom CSS Animations

Defining Transition Animations

/* Default cross-fade animation */
::view-transition-old(root) {
  animation: fade-out 0.3s ease-out;
}

::view-transition-new(root) {
  animation: fade-in 0.3s ease-out;
}

/* Slide transitions for navigation */
.transition-forward::view-transition-old(root) {
  animation: slide-out-left 0.3s ease-out;
}

.transition-forward::view-transition-new(root) {
  animation: slide-in-right 0.3s ease-out;
}

.transition-back::view-transition-old(root) {
  animation: slide-out-right 0.3s ease-out;
}

.transition-back::view-transition-new(root) {
  animation: slide-in-left 0.3s ease-out;
}

/* Shared element transitions */
::view-transition-group(product-image) {
  animation-duration: 0.4s;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

/* Morph animation for shared elements */
@keyframes morph {
  from {
    border-radius: 8px;
  }
  to {
    border-radius: 0;
  }
}

::view-transition-image-pair(hero-image) {
  animation: morph 0.4s;
}

/* Stagger animations for lists */
::view-transition-group(list-item-1) {
  animation-delay: 0ms;
}
::view-transition-group(list-item-2) {
  animation-delay: 50ms;
}
::view-transition-group(list-item-3) {
  animation-delay: 100ms;
}

Programmatic Animation Control

const useAnimatedTransition = () => {
  const applyTransition = useCallback(async (
    updateDOM: () => void,
    animation: AnimationConfig
  ) => {
    if (!document.startViewTransition) {
      updateDOM();
      return;
    }

    const transition = document.startViewTransition(updateDOM);

    await transition.ready;

    // Get all transition pseudo-elements
    const oldElement = document.documentElement.querySelector(
      '::view-transition-old(root)'
    );
    const newElement = document.documentElement.querySelector(
      '::view-transition-new(root)'
    );

    // Apply custom animations using Web Animations API
    if (oldElement) {
      oldElement.animate(
        animation.old.keyframes,
        animation.old.options
      );
    }

    if (newElement) {
      newElement.animate(
        animation.new.keyframes,
        animation.new.options
      );
    }

    await transition.finished;
  }, []);

  return { applyTransition };
};

interface AnimationConfig {
  old: {
    keyframes: Keyframe[];
    options: KeyframeAnimationOptions;
  };
  new: {
    keyframes: Keyframe[];
    options: KeyframeAnimationOptions;
  };
}

// Usage example
const CustomTransition: React.FC = () => {
  const { applyTransition } = useAnimatedTransition();
  const [state, setState] = useState('initial');

  const handleTransition = () => {
    applyTransition(
      () => setState('updated'),
      {
        old: {
          keyframes: [
            { opacity: 1, transform: 'scale(1) rotate(0deg)' },
            { opacity: 0, transform: 'scale(0.8) rotate(-10deg)' }
          ],
          options: { duration: 300, easing: 'ease-out' }
        },
        new: {
          keyframes: [
            { opacity: 0, transform: 'scale(1.2) rotate(10deg)' },
            { opacity: 1, transform: 'scale(1) rotate(0deg)' }
          ],
          options: { duration: 300, easing: 'ease-out' }
        }
      }
    );
  };

  return <div onClick={handleTransition}>{state}</div>;
};

Performance Optimization

Reducing Paint Complexity

const OptimizedTransition: React.FC = () => {
  const prepareForTransition = useCallback(() => {
    // Reduce paint complexity before transition
    document.body.classList.add('transitioning');

    // Disable animations on non-critical elements
    document.querySelectorAll('.animation-heavy').forEach(el => {
      (el as HTMLElement).style.animation = 'none';
    });

    // Use will-change for optimization hints
    document.documentElement.style.willChange = 'transform, opacity';
  }, []);

  const cleanupAfterTransition = useCallback(() => {
    document.body.classList.remove('transitioning');

    // Re-enable animations
    document.querySelectorAll('.animation-heavy').forEach(el => {
      (el as HTMLElement).style.animation = '';
    });

    // Clear will-change
    document.documentElement.style.willChange = 'auto';
  }, []);

  const optimizedNavigate = useCallback(async (to: string) => {
    prepareForTransition();

    if (document.startViewTransition) {
      const transition = document.startViewTransition(() => {
        navigate(to);
      });

      await transition.finished;
    } else {
      navigate(to);
    }

    cleanupAfterTransition();
  }, [prepareForTransition, cleanupAfterTransition]);

  return <NavigationMenu onNavigate={optimizedNavigate} />;
};

Conditional Transitions Based on Device

const useAdaptiveTransitions = () => {
  const [transitionsEnabled, setTransitionsEnabled] = useState(true);

  useEffect(() => {
    // Check device capabilities
    const checkCapabilities = () => {
      // Disable on low-end devices
      const memory = (navigator as any).deviceMemory;
      if (memory && memory < 4) {
        setTransitionsEnabled(false);
        return;
      }

      // Disable on slow connections
      const connection = (navigator as any).connection;
      if (connection?.saveData || connection?.effectiveType === '2g') {
        setTransitionsEnabled(false);
        return;
      }

      // Check for reduced motion preference
      const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      setTransitionsEnabled(!prefersReducedMotion);
    };

    checkCapabilities();

    // Listen for changes
    window
      .matchMedia('(prefers-reduced-motion: reduce)')
      .addEventListener('change', checkCapabilities);

    return () => {
      window
        .matchMedia('(prefers-reduced-motion: reduce)')
        .removeEventListener('change', checkCapabilities);
    };
  }, []);

  const navigate = useCallback(
    (to: string) => {
      if (transitionsEnabled && document.startViewTransition) {
        document.startViewTransition(() => {
          window.location.href = to;
        });
      } else {
        window.location.href = to;
      }
    },
    [transitionsEnabled],
  );

  return { navigate, transitionsEnabled };
};

Fallback Strategies

Progressive Enhancement

class TransitionFallback {
  private supportsViewTransitions = 'startViewTransition' in document;
  private fallbackStrategy: 'css' | 'js' | 'none';

  constructor() {
    this.fallbackStrategy = this.detectFallbackStrategy();
  }

  private detectFallbackStrategy(): 'css' | 'js' | 'none' {
    if (this.supportsViewTransitions) return 'none';

    // Check for CSS animation support
    const animationSupport = 'animation' in document.documentElement.style;
    if (animationSupport) return 'css';

    // Check for requestAnimationFrame
    if ('requestAnimationFrame' in window) return 'js';

    return 'none';
  }

  async transition(updateDOM: () => void, options?: TransitionOptions) {
    switch (this.fallbackStrategy) {
      case 'none':
        // Use native View Transitions
        if (document.startViewTransition) {
          await document.startViewTransition(updateDOM).finished;
        } else {
          updateDOM();
        }
        break;

      case 'css':
        await this.cssFallback(updateDOM, options);
        break;

      case 'js':
        await this.jsFallback(updateDOM, options);
        break;

      default:
        updateDOM();
    }
  }

  private async cssFallback(updateDOM: () => void, options?: TransitionOptions) {
    const container = document.getElementById('root');
    if (!container) {
      updateDOM();
      return;
    }

    // Add transition class
    container.classList.add('transitioning-out');

    // Wait for animation
    await new Promise((resolve) => setTimeout(resolve, 300));

    // Update DOM
    updateDOM();

    // Transition in
    container.classList.remove('transitioning-out');
    container.classList.add('transitioning-in');

    // Cleanup
    await new Promise((resolve) => setTimeout(resolve, 300));
    container.classList.remove('transitioning-in');
  }

  private async jsFallback(updateDOM: () => void, options?: TransitionOptions) {
    const container = document.getElementById('root');
    if (!container) {
      updateDOM();
      return;
    }

    // Fade out
    await this.animate(container, [{ opacity: '1' }, { opacity: '0' }], { duration: 300 });

    // Update DOM
    updateDOM();

    // Fade in
    await this.animate(container, [{ opacity: '0' }, { opacity: '1' }], { duration: 300 });
  }

  private animate(
    element: Element,
    keyframes: Keyframe[],
    options: KeyframeAnimationOptions,
  ): Promise<void> {
    return new Promise((resolve) => {
      const animation = element.animate(keyframes, options);
      animation.onfinish = () => resolve();
    });
  }
}

Integration with State Management

Redux Integration

const useReduxTransition = () => {
  const dispatch = useDispatch();

  const dispatchWithTransition = useCallback(async (action: AnyAction) => {
    if (!document.startViewTransition) {
      dispatch(action);
      return;
    }

    const transition = document.startViewTransition(() => {
      dispatch(action);
    });

    try {
      await transition.finished;
    } catch (error) {
      console.error('Transition failed:', error);
    }
  }, [dispatch]);

  return { dispatchWithTransition };
};

// Usage in component
const TodoList: React.FC = () => {
  const todos = useSelector(selectTodos);
  const { dispatchWithTransition } = useReduxTransition();

  const handleComplete = (id: string) => {
    dispatchWithTransition(completeTodo(id));
  };

  return (
    <ul>
      {todos.map(todo => (
        <li
          key={todo.id}
          style={{ viewTransitionName: `todo-${todo.id}` } as React.CSSProperties}
        >
          <button onClick={() => handleComplete(todo.id)}>
            {todo.text}
          </button>
        </li>
      ))}
    </ul>
  );
};

Best Practices Checklist

Design meaningful transitions:

  • Use shared elements for continuity
  • Match transition direction to user intent
  • Keep durations under 400ms

Optimize for performance:

  • Reduce paint complexity during transitions
  • Use will-change sparingly
  • Disable on low-end devices

Provide fallbacks:

  • Feature detect View Transitions API
  • Implement CSS/JS fallbacks
  • Respect prefers-reduced-motion

Test thoroughly:

  • Test on various devices
  • Monitor transition performance
  • Ensure accessibility

Use transitions purposefully:

  • Guide user attention
  • Maintain spatial context
  • Communicate state changes

Last modified on .