View Transitions API: Building Smooth Page Animations Without JavaScript

AJ
18 min read

For years, building smooth page transitions on the web meant reaching for JavaScript animation libraries. Framer Motion, GSAP, Barba.js — we stacked dependency on dependency just to get a simple crossfade between routes. Native mobile apps had buttery smooth transitions built into the platform, while the web felt stuck with jarring, instant page swaps. That era is over. The View Transitions API is a browser-native solution that lets you animate between DOM states — including full page navigations — with CSS alone. No JavaScript animation libraries required. No complex state management. No fighting with AnimatePresence. The browser captures a snapshot of your old page, captures the new page, and animates between them using CSS. It's the single biggest improvement to web animations since CSS transitions were introduced, and it fundamentally changes how we think about page navigation on the web.

In this guide, we'll go deep into the View Transitions API. We'll cover the fundamentals of how it works under the hood, how to implement it in vanilla JavaScript and in Next.js applications, how to build shared element transitions that rival native mobile apps, and how to create advanced animation patterns that will make your users think they're using a native application. Whether you're building a single-page app, a multi-page site, or anything in between, the View Transitions API gives you a powerful new primitive for creating delightful navigation experiences.

How the View Transitions API Works

Before we write any code, let's understand what the browser is actually doing when you trigger a view transition. The process is surprisingly elegant and happens in four distinct phases. The browser captures a screenshot of the current DOM state, takes a snapshot, creates a tree of pseudo-elements to hold both the old and new states, animates between them using CSS animations, and finally reveals the new DOM state. This all happens in a single frame-synchronous operation, which is why it feels so smooth.

View Transitions API: Complete Lifecycle

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  1. CAPTURE      │────▶│  2. SNAPSHOT     │────▶│  3. ANIMATE      │────▶│  4. REVEAL       │
│  Old DOM State   │     │  Create Pseudo   │     │  CSS Transitions │     │  New DOM State   │
│                  │     │  Element Tree    │     │  Between States  │     │                  │
│  Browser takes   │     │  ::view-transition│     │  Old fades out   │     │  Pseudo-elements │
│  a rasterized    │     │  root element    │     │  New fades in    │     │  are removed     │
│  screenshot of   │     │  holds both old  │     │  (or custom      │     │  New DOM is now  │
│  current page    │     │  and new images  │     │  animation)      │     │  fully visible   │
└─────────────────┘     └─────────────────┘     └─────────────────┘     └─────────────────┘

         │                       │                       │                       │
         ▼                       ▼                       ▼                       ▼
   document.start         Callback runs           CSS @keyframes          transition.finished
   ViewTransition()       to update DOM           execute on              Promise resolves
   is called              (sync or async)         pseudo-elements

Timeline:  ──────────────────────────────────────────────────────────────────────────▶
           Frame 0              Frame 1            Frames 2-N              Final Frame

Key Insight: The old state is a SCREENSHOT (raster image).
             The new state is a LIVE DOM snapshot.
             The browser animates between these two layers.

The critical insight here is that the old state is a rasterized image — a flat screenshot. It's not live DOM anymore. The new state, however, is the actual live DOM. This means the transition is essentially animating from a picture of the old page to the real new page. This design choice is what makes view transitions so performant. The browser doesn't need to keep two live DOM trees in memory. It only needs one screenshot and one live DOM.

Browser Support and Progressive Enhancement

As of early 2026, the View Transitions API has excellent browser support. Chrome and Edge have had full support since version 111. Safari added support in version 18. Firefox shipped support in version 129. This means the vast majority of users on modern browsers can experience view transitions. However, you should always implement progressive enhancement so that users on older browsers still get a functional experience — they just won't see the animations.

Feature Detection

The best practice is to check for API support before attempting to use it. If the API isn't available, simply update the DOM directly without any transition. The user still gets the correct content — they just don't see the animation. This is the beauty of progressive enhancement: the core functionality always works, and the animation is a layer on top.

feature-detection.ts

Fallback Strategies

  • Use @supports (view-transition-name: none) in CSS to conditionally apply transition styles.
  • Check document.startViewTransition existence in JavaScript before calling it.
  • Provide CSS-only fallback animations using standard @keyframes for older browsers.
  • Use a polyfill like view-transitions-polyfill if you absolutely need cross-browser support now, though native support is broad enough for most production apps.
  • Test with DevTools throttling to ensure transitions degrade gracefully on slower devices.

The Pseudo-Element Architecture

When a view transition runs, the browser creates a tree of pseudo-elements that overlay the page. Understanding this tree is essential for customizing your transitions. The root pseudo-element contains groups, and each group contains an old and new image pair. By default, there's just one group for the entire page (called "root"), but you can create named groups for individual elements to get shared element transitions.

Pseudo-Element Tree During a View Transition

::view-transition                          ← Root overlay (covers entire viewport)
├── ::view-transition-group(root)          ← Container for the full-page transition
│   ├── ::view-transition-image-pair(root) ← Holds old + new screenshots
│   │   ├── ::view-transition-old(root)    ← Screenshot of OLD page (fading out)
│   │   └── ::view-transition-new(root)    ← Live snapshot of NEW page (fading in)
│   │
├── ::view-transition-group(hero-image)    ← Named group (shared element)
│   ├── ::view-transition-image-pair(hero-image)
│   │   ├── ::view-transition-old(hero-image)  ← Old hero image position/size
│   │   └── ::view-transition-new(hero-image)  ← New hero image position/size
│   │
├── ::view-transition-group(page-title)    ← Another named group
│   ├── ::view-transition-image-pair(page-title)
│   │   ├── ::view-transition-old(page-title)
│   │   └── ::view-transition-new(page-title)
│   │
└── ::view-transition-group(card-42)       ← Dynamic named group
    ├── ::view-transition-image-pair(card-42)
    │   ├── ::view-transition-old(card-42)
    │   └── ::view-transition-new(card-42)

Key Points:
• ::view-transition-old is ALWAYS a replaced element (like <img>)
• ::view-transition-new is ALSO a replaced element (screenshot of new state)
• ::view-transition-group animates width, height, and transform
• You can target ANY of these with CSS to customize animations
• Named groups enable shared element transitions (elements morph between states)

Each pseudo-element in this tree can be targeted with CSS. The ::view-transition-old and ::view-transition-new pseudo-elements are what you'll style most often. By default, the old state fades out while the new state fades in. But you can override this with any CSS animation you want — slides, scales, rotations, or completely custom keyframes.

Basic View Transitions

Let's start with the simplest possible view transition. The document.startViewTransition() method is the entry point for all same-document transitions. You pass it a callback function that updates the DOM. The browser handles everything else — capturing the old state, running the callback, capturing the new state, and animating between them.

Same-Document Transitions

basic-transition.ts

Cross-Document Transitions (Multi-Page Apps)

Cross-document view transitions work for traditional multi-page applications where each navigation is a full page load. This is huge because it means server-rendered sites, PHP apps, Rails apps, and even static HTML sites can now have smooth page transitions without a single line of JavaScript. You enable them with a CSS rule and a meta tag.

cross-document.html

Cross-document transitions are the most exciting part of this API. They bring SPA-like smoothness to traditional multi-page architectures. Your server-rendered pages can now transition as smoothly as a React SPA, without any client-side routing framework. The only requirement is that both pages are on the same origin and both opt in with the meta tag.

Styling the Pseudo-Elements

The default transition is a simple crossfade, which works well for many cases. But you can completely customize the animation by targeting the pseudo-elements with CSS. The ::view-transition-old pseudo-element represents the outgoing state, and ::view-transition-new represents the incoming state. You can apply any CSS animation to either of them.

custom-transitions.css

View Transitions in Next.js

Next.js has embraced the View Transitions API with experimental support in the App Router. This integration makes it incredibly easy to add smooth route transitions to your Next.js applications. The framework handles the timing of DOM updates and coordinates with the browser's transition lifecycle, so you can focus on designing the animations rather than managing state.

Setting Up View Transitions in Next.js

next.config.ts

With the flag enabled, Next.js will automatically wrap route changes in document.startViewTransition(). Every time a user navigates between pages using next/link or router.push(), the browser will capture the old state, update to the new route, and animate between them. The default animation is a crossfade, but you can customize it with CSS.

app/globals.css

Route-Aware Transitions with Next.js

One of the most powerful patterns is creating different transitions based on the navigation direction. When a user navigates deeper into your app (e.g., from a list to a detail page), you might want a forward slide. When they go back, a reverse slide. You can achieve this by setting CSS custom properties or classes on the document before the transition starts.

hooks/use-directional-transition.ts

The Transition Lifecycle

Understanding the exact sequence of events during a view transition is critical for building reliable animations. Here is the complete lifecycle, showing when each pseudo-element is created, when your callback runs, and when the animation plays.

View Transition Lifecycle (Detailed)

  startViewTransition(callback)
              │
              ▼
  ┌───────────────────────┐
  │ 1. CAPTURE OLD STATE  │  Browser renders current DOM to an image
  │    - Rasterize page   │  Each named view-transition-name element
  │    - Store snapshots  │  gets its own separate snapshot
  └───────────┬───────────┘
              │
              ▼
  ┌───────────────────────┐
  │ 2. RUN CALLBACK       │  Your callback() executes here
  │    - Update the DOM   │  This can be sync or async
  │    - Fetch data       │  Page is frozen (no rendering) until done
  │    - Change route     │
  └───────────┬───────────┘
              │
              ▼                    transition.updateCallbackDone resolves
  ┌───────────────────────┐
  │ 3. CAPTURE NEW STATE  │  Browser renders updated DOM to an image
  │    - Rasterize again  │  Matches named elements old ↔ new
  │    - Build pairs      │
  └───────────┬───────────┘
              │
              ▼                    transition.ready resolves
  ┌───────────────────────┐
  │ 4. CREATE PSEUDO TREE │  ::view-transition pseudo-elements appear
  │    - ::v-t-old(*)     │  Old state = static image
  │    - ::v-t-new(*)     │  New state = static image (becomes live at end)
  │    - ::v-t-group(*)   │  Groups animate size/position
  └───────────┬───────────┘
              │
              ▼
  ┌───────────────────────┐
  │ 5. ANIMATE            │  CSS animations run on pseudo-elements
  │    - Default: fade    │  Default duration: 250ms
  │    - Custom: anything │  You can override with CSS
  │    - GPU accelerated  │  Composited layer = smooth 60fps
  └───────────┬───────────┘
              │
              ▼                    transition.finished resolves
  ┌───────────────────────┐
  │ 6. CLEANUP            │  Pseudo-elements removed
  │    - Remove overlays  │  Live DOM is now visible
  │    - Show real DOM    │  User interacts with actual page
  └───────────────────────┘

Shared Element Transitions

Shared element transitions are where the View Transitions API truly shines. By giving elements matching view-transition-name values on different pages, the browser will automatically animate the element from its old position and size to its new position and size. This creates the kind of fluid, connected transitions you see in native mobile apps — a thumbnail growing into a full hero image, a card expanding into a detail page, or a title morphing from a list item into a page header.

The view-transition-name Property

The view-transition-name CSS property is the key to shared element transitions. When the browser captures snapshots, it groups elements with the same transition name together. The old snapshot and new snapshot of a named element form a pair, and the browser animates between them. There's one important rule: every view-transition-name must be unique within a page at the time of capture. You cannot have two visible elements with the same name.

shared-element.tsx

When a user clicks a product card, the browser captures the thumbnail image and title at their current positions and sizes. After the route changes and the detail page renders, the browser sees the same view-transition-name values on the hero image and page title. It then smoothly animates the image from its small thumbnail size to the full-width hero, and morphs the title from a small font to a large heading. The result is a fluid, connected navigation that tells a visual story.

Card Expand Animations

One of the most satisfying patterns is expanding a card from a grid into a full detail view. The card seems to grow and fill the page, with its contents rearranging smoothly. This is achieved by naming multiple elements within the card and having corresponding named elements on the detail page.

card-expand.css

Advanced Animation Patterns

Once you understand the basics, you can create some truly impressive animation patterns. The View Transitions API is flexible enough to handle directional slides, morphing shapes, staggered group animations, and even combining multiple techniques for cinematic effects. Let's explore some advanced patterns that will take your transitions to the next level.

Directional Transitions Based on Navigation

One of the most polished patterns is sliding pages in different directions based on navigation. Forward navigation slides the new page in from the right. Back navigation slides it in from the left. This mirrors the native behavior on iOS and Android, making your web app feel like a first-class citizen on mobile devices.

directional-transitions.css

Staggered Group Transitions

When transitioning pages that have multiple distinct sections (a header, a sidebar, main content, and a footer), you can stagger the transitions so each section animates in sequence. This creates a cascading reveal effect that feels incredibly polished. The key is giving each section its own view-transition-name and then applying incrementally delayed animations.

staggered-transitions.css

Morphing Transitions

Morphing transitions are when an element changes shape, size, and position simultaneously. The ::view-transition-group pseudo-element handles this automatically when you use shared transition names. It interpolates the width, height, and transform between the old and new states. You can enhance this with custom timing functions to create elastic, bouncy, or snappy morphs.

morph-transitions.css

Transition Pattern Reference

Here is a visual reference for the most common transition patterns and when to use each one. Choosing the right transition for the right context is important. A slide transition makes sense for navigation depth changes, while a crossfade works better for lateral navigation. Morphs are ideal for shared elements, and expands work for drill-down interactions.

Common Transition Patterns

1. CROSSFADE (default)            2. SLIDE
   Best for: lateral navigation      Best for: forward/back navigation

   ┌─────────┐  ┌─────────┐         ┌─────────┐  ┌─────────┐
   │ Old Page │  │ New Page │         │ Old Page│──▶│New Page │
   │  ░░░░░  │→ │  ████   │         │ ◀──────│  │──────▶ │
   │  ░░░░░  │  │  ████   │         └─────────┘  └─────────┘
   └─────────┘  └─────────┘         Old slides out, new slides in
   Opacity 1→0    Opacity 0→1       from the navigation direction

3. MORPH (shared element)         4. EXPAND (drill-down)

   ┌───┐                            ┌─────────────────┐
   │ A │ ──────────▶ ┌────────┐     │  ┌───┐          │
   └───┘             │   A    │     │  │ X │ ────┐    │
   Small box morphs  │        │     │  └───┘     │    │
   to large box:     │        │     │            ▼    │
   size + position   └────────┘     │  ┌─────────────┐│
   interpolated                     │  │      X      ││
                                    │  │  (expanded)  ││
                                    │  └─────────────┘│
                                    └─────────────────┘

5. STAGGER (cascading reveal)     6. ZOOM (modal/overlay)

   ┌──────────────────┐            ┌─────────┐
   │ ████ Header      │ ←delay 0   │         │
   │ ████ Nav         │ ←delay 50  │  ┌───┐  │
   │ ████ Content     │ ←delay 100 │  │ · │──┼──▶ ████████
   │ ████ Footer      │ ←delay 150 │  └───┘  │    ████████
   └──────────────────┘            └─────────┘    ████████
   Each section appears             Small element zooms
   sequentially                     to fill the viewport

Choose Your Pattern:
• Same-level navigation    → Crossfade
• Deeper/shallower nav     → Slide (directional)
• Element persists across  → Morph (shared element)
• List item to detail      → Expand
• Multiple content areas   → Stagger
• Opening overlays         → Zoom

Integration with Component Libraries

View Transitions work beautifully with component libraries like shadcn/ui and Spectrum UI. Since transitions are driven by CSS and browser-level APIs, they're compatible with any component framework. The key is knowing where to apply view-transition-name and how to coordinate transitions with component state changes. Here are some practical integration patterns.

Tabs with View Transitions

Tab components are a perfect candidate for view transitions. Instead of content instantly swapping when the user clicks a tab, you can make the content crossfade or slide. This also works with the active tab indicator — give it a view-transition-name and it will smoothly morph between tab positions.

tabs-with-transitions.tsx

Dialog Open/Close Transitions

Dialogs and modals can benefit from view transitions too. Instead of using JavaScript-driven spring animations for dialog open and close, you can use view transitions to smoothly morph a trigger element into the dialog. This creates a connected, spatial animation where the dialog appears to expand from the button that opened it.

Page Layout Transitions

For Spectrum UI components that manage page layouts — like sidebars, headers, and content areas — view transitions provide a way to animate layout changes. Collapsing a sidebar, switching between grid and list views, or toggling a panel can all be wrapped in startViewTransition() for smooth state changes. The browser handles the interpolation between the old and new layout, including size and position changes of every named element.

Performance Best Practices

View transitions are designed to be performant by default, but there are still ways to optimize them and pitfalls to avoid. The browser composites the transition on the GPU, which means you get 60fps animations for free in most cases. However, certain patterns can cause performance issues, especially on lower-end devices.

Avoiding Layout Shifts

The biggest performance killer with view transitions is layout shift during the DOM update callback. If your callback causes the page layout to jump (e.g., content reflowing because new content has different dimensions), the transition will capture a mid-shift state and the animation will look janky. To avoid this, make sure your new content has predictable dimensions. Use CSS Grid or fixed-height containers where possible. Preload images that will appear in the new state so they don't cause layout shifts when they load.

  • Use aspect-ratio on images and media to reserve space before they load.
  • Avoid transitions that change the scroll position — capture happens at the current scroll offset.
  • Keep your DOM update callback fast. The page is frozen while it runs, so slow callbacks cause visible freezes.
  • For async callbacks, show a loading state quickly rather than waiting for slow network requests.
  • Use content-visibility: auto on off-screen content to reduce the capture cost.

GPU-Accelerated Properties

The pseudo-elements created during a view transition are composited on the GPU by default. This means transform and opacity animations are free. However, if you add CSS properties that force paint operations (like filter, backdrop-filter, or clip-path animations), you may see dropped frames. Stick to transforms and opacity for the actual animation keyframes. Use other properties for the static initial and final states, but don't animate them.

Reducing the Number of Snapshots

Every element with a view-transition-name generates its own snapshot pair. The browser has to rasterize each one independently. If you have 50 named elements on a page, that's 100 snapshots the browser needs to capture and composite. On a high-end laptop, this is fine. On a budget Android phone, it might cause a noticeable delay before the animation starts. Be judicious with named elements. Transition the three or four most important elements, and let the rest participate in the default root crossfade.

Accessibility Considerations

Animations enhance the experience for most users but can be problematic for people with vestibular disorders, motion sensitivity, or cognitive disabilities. Respecting user preferences and ensuring your transitions don't create barriers is not optional — it's a fundamental part of building for the web.

Respecting prefers-reduced-motion

The most important accessibility measure is honoring the prefers-reduced-motion media query. Users who enable this setting on their operating system have explicitly asked for less motion. Your view transitions should either be disabled entirely or replaced with minimal, non-moving alternatives like a simple opacity change.

reduced-motion.css

Screen Reader Behavior

View transitions don't affect screen reader behavior directly. The accessibility tree is updated based on the DOM changes, not the visual transition. However, you should ensure that focus management is correct after the transition completes. When navigating to a new page, focus should move to the main content area or the page heading. Use transition.finished.then(() => focusMainContent()) to manage focus after the animation completes. Also ensure that ARIA live regions announce any important content changes that happen during the transition, since the visual animation might mask the fact that content has changed.

Duration and Easing Guidelines

Keep transition durations between 200ms and 400ms. Anything shorter than 150ms is too fast to register. Anything longer than 500ms feels sluggish and can trigger discomfort in motion-sensitive users. Use ease-out for entrances (content decelerating into place) and ease-in for exits (content accelerating away). Avoid linear timing, which feels robotic. The cubic-bezier(0.4, 0, 0.2, 1) curve (Material Design's standard easing) is a safe default for most transitions.

Common Pitfalls and Debugging Tips

Even though the View Transitions API is relatively straightforward, there are some common mistakes that can trip you up. Here are the issues I see most often and how to fix them.

  • Duplicate view-transition-name values: Every visible element must have a unique view-transition-name at capture time. If two elements share a name, the transition will fail silently. Use dynamic names like card-{id} for lists.
  • Transition names on hidden elements: If an element with a view-transition-name is display: none, it won't be captured. This is usually fine, but can cause confusion if you expect it to participate in the transition.
  • Slow async callbacks: The page is frozen during the callback. If your callback takes 2 seconds to fetch data, the user sees a frozen page for 2 seconds. Either preload data or update the DOM instantly with a skeleton and fill in data later.
  • Z-index conflicts: View transition pseudo-elements render in a layer above the page. If you have z-index values in the thousands, they can conflict. The ::view-transition root has its own stacking context.
  • Scrolling during transitions: The page is unscrollable during a transition. Keep animations short (under 400ms) so users don't notice the scroll lock.
  • Testing tip: Chrome DevTools has a "View Transitions" panel under the Animations tab. You can slow down, pause, and step through transitions frame by frame. This is invaluable for debugging timing issues.

Implementation Checklist

  • Enable experimental.viewTransition in your Next.js config (or add the meta tag for MPAs).
  • Add a default crossfade transition in your global CSS targeting ::view-transition-old(root) and ::view-transition-new(root).
  • Identify 2-3 key elements that should have shared transitions between pages and give them unique view-transition-name values.
  • Add directional transition logic if your app has a clear navigation hierarchy (list → detail → sub-detail).
  • Keep persistent elements (nav bars, footers) stable by giving them a view-transition-name and setting animation: none on their group.
  • Add @media (prefers-reduced-motion: reduce) rules that disable or simplify all transition animations.
  • Test on mobile devices and throttled connections to ensure transitions feel smooth.
  • Use Chrome DevTools View Transitions panel to debug timing, stacking, and snapshot issues.
  • Manage focus after transitions complete — use transition.finished to set focus appropriately.
  • Feature-detect before using the API: always wrap startViewTransition calls with a support check so older browsers work without errors.
  • Keep the total number of named elements under 10 per page for optimal performance on all devices.
  • Prefer short durations (200-400ms) with ease-out curves for natural-feeling animations.

The Bottom Line

The View Transitions API represents a fundamental shift in how we build web animations. For the first time, the browser gives us a native, performant, CSS-driven way to animate between page states. No JavaScript animation libraries. No complex state management for enter/exit animations. No fighting with layout thrashing. Just declare your transition names in CSS, let the browser handle the snapshot and compositing, and customize the animations with standard CSS keyframes.

The practical impact is enormous. SPAs get smoother route transitions with less JavaScript. MPAs get SPA-like transitions with zero JavaScript. Shared element transitions — once the exclusive domain of native mobile apps — are now achievable on the web with a single CSS property. And because the API is built on progressive enhancement, adding view transitions to an existing app is zero-risk. Browsers that don't support it simply skip the animation. No broken functionality. No error handling required.

Start with the basics: enable the API, add a global crossfade, and identify two or three elements that should have shared transitions. Once you see how little code is needed for such a significant UX improvement, you'll wonder how we ever built web apps without it. The web finally feels like a first-class platform for navigation animations, and the View Transitions API is the reason why.

Get notified about new templates & components

Join 4,000+ developers. No spam, ever.

Related Articles

Spectrum Pro

Stop building from scratch.
Ship faster with Pro templates.

Premium Next.js templates built on Spectrum UI. Dark. Animated. Production-ready.