avatar
React's <ViewTransition>: Browser-Native Animation, Done the React Way
avatar

yceffort

ยท16 min read
reactcssnextjs

Table of Contents

Overview

Adding animations during page transitions or UI state changes on the web has traditionally required writing CSS transition/animation manually or relying on libraries like Framer Motion. Transitions where "the old state disappears and a new state appears" are particularly tricky because you need to keep both states in the DOM simultaneously while orchestrating the animation.

The View Transition API solves this problem at the browser level. Instead of developers managing two states simultaneously, the browser takes snapshots before and after the transition and creates the animation automatically.

What Is the View Transition API

Here's how it works:

document.startViewTransition(() => {
  // Modify the DOM inside this callback
  container.innerHTML = newContent
})

When startViewTransition is called, the browser goes through three stages:

  1. Capture: Captures the current screen as a bitmap snapshot (::view-transition-old)
  2. Change: Executes the callback to update the DOM
  3. Transition: Captures the new DOM state (::view-transition-new) and applies a cross-fade animation between old and new

By default, the entire page cross-fades, but you can designate individual elements with the view-transition-name CSS property to animate them separately. If elements with the same view-transition-name exist before and after the transition, the browser creates a shared element animation that automatically interpolates position, size, and shape.

.thumbnail {
  view-transition-name: hero-image;
}

This alone creates an animation where a small thumbnail on a list page naturally scales up and moves to a large image on a detail page. No additional CSS required.

The problem is that this API assumes DOM changes happen synchronously within the callback. React doesn't guarantee this.

The React team solved this with a <ViewTransition> component. As of March 2026, it's available in the react@canary channel but not yet included in stable releases. However, the React Labs blog post (2025.04) states that it's been tested in production and the API design is nearly finalized, so it's worth exploring early.

Why React Needs a Dedicated Component

Without React, using the View Transition API is straightforward โ€” just modify the DOM directly inside the startViewTransition callback. The DOM change must be synchronous. When startViewTransition is called, the browser (1) captures the current screen as a snapshot, (2) executes the callback, and (3) captures the new DOM state when the callback returns. If the DOM hasn't changed by the time the callback returns, the old and new snapshots are identical and no transition animation occurs.

In React, setState is batched asynchronously, so you need to force synchronous rendering with flushSync.

function handleClick() {
  document.startViewTransition(() => {
    flushSync(() => {
      setState(newState)
    })
  })
}

This approach hits concrete problems in real usage.

Using it with Suspense causes fallbacks to reappear. flushSync can force pending Suspense boundaries back to their fallback state. You might be showing content after data has loaded, only to have the skeleton flash back due to a single flushSync call. React's official documentation explicitly warns that flushSync can cause Suspense fallbacks to reappear.

Conflicts with other flushSync calls cause View Transitions to be skipped entirely. React Transitions must complete synchronously, but if another flushSync intervenes, React abandons the Transition sequence. This can happen in real apps with overlapping user interactions, leading to hard-to-debug bugs where animations intermittently stop working.

Fundamentally incompatible with Concurrent features. Updates wrapped in startTransition can be intentionally delayed, and components inside Suspense can suspend rendering while waiting for data. This contradicts the View Transition API's requirement for "immediate DOM changes within the callback."

The <ViewTransition> component solves this from within React. Since React controls the rendering cycle, it can call startViewTransition at the exact moment DOM updates complete, automatically coordinating with Suspense boundaries and Concurrent rendering.

Vanilla JSReact + flushSync<ViewTransition>
DOM timingDirect controlUnpredictableReact coordinates
Suspense integrationNot possibleRisk of fallback reappearAutomatic support
view-transition-nameManual CSSManual CSSAutomatic
Concurrent renderingN/AIncompatibleAutomatic support

Comparison with Other Frameworks

Compared to other frameworks, React's approach is notably heavy. The reason lies in the differences in rendering models.

SvelteKit operates on Svelte 5's signal-based fine-grained reactivity. When a $state value changes, only the DOM nodes that depend on it are updated directly. There's no virtual DOM diffing, and changes are reflected in the DOM immediately when they occur. This makes View Transition integration surprisingly simple โ€” it just provides an onNavigate lifecycle hook.

// +layout.svelte
import {onNavigate} from '$app/navigation'

onNavigate((navigation) => {
  if (!document.startViewTransition) return

  return new Promise((resolve) => {
    document.startViewTransition(async () => {
      resolve()
      await navigation.complete
    })
  })
})

SvelteKit officially states that it "doesn't greatly abstract over how View Transitions work โ€” you're using the browser's built-in API directly". This is possible because the framework doesn't need to control DOM timing.

Angular just needs to add withViewTransitions() to the router. Since the DOM is updated synchronously during the Change Detection cycle, there are no timing issues within startViewTransition callbacks.

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes, withViewTransitions())],
}

Nuxt (Vue) requires just one configuration line (experimental.viewTransition: true). Vue's reactivity system batch-updates in the microtask queue, but DOM change completion timing is predictable via nextTick.

React combines virtual DOM diffing, Concurrent Rendering, Suspense, and automatic batching so that "when the DOM changes" is known only to the framework and not exposed to developers. Since the View Transition API must intervene at precisely that timing, a dedicated component was necessary.

Abstraction LevelDOM UpdatesView Transition Integration
SvelteKitMinimal (one hook)Signal-based direct updatesNative API used directly in onNavigate
AngularOne router config lineSynchronous (Change Detection)withViewTransitions() auto-connects router
Nuxt (Vue)One config lineMicrotask batching (predictable)experimental.viewTransition: true
ReactDedicated component + APIAsynchronous (vDOM, Concurrent)<ViewTransition> + addTransitionType

React's approach is the heaviest, but it enables things impossible in other frameworks. Animations across Suspense boundaries, automatic integration with useDeferredValue, and declarative shared element matching are possible because React controls the entire rendering process.

Core Structure: What, When, How

What โ€” What to Animate

Wrap it with <ViewTransition>.

<ViewTransition>
  <div>This element becomes the animation target</div>
</ViewTransition>

When โ€” When Animations Trigger

Three triggers exist:

  • startTransition(() => setState(...))
  • useDeferredValue(value)
  • When a <Suspense> fallback transitions to actual content

Regular setState() won't trigger animations. This is intentional design โ€” animating every state change would actually worsen UX.

// โŒ No animation triggered
const handleClick = () => {
  setShowDetail(true)
}

// โœ… Animation triggered
const handleClick = () => {
  startTransition(() => {
    setShowDetail(true)
  })
}

How โ€” How to Animate

Define with CSS View Transition pseudo-selectors. Without custom CSS, the default cross-fade is applied.

::view-transition-old(.slow-fade) {
  animation-duration: 500ms;
}

::view-transition-new(.slow-fade) {
  animation-duration: 500ms;
}

When CSS isn't enough, callbacks are available. Four callbacks exist: onEnter, onExit, onUpdate, onShare, each receiving the animated DOM element and a transition type array as arguments.

<ViewTransition
  onEnter={(element, types) => {
    element.animate(
      [
        {transform: 'scale(0.8)', opacity: 0},
        {transform: 'scale(1)', opacity: 1},
      ],
      {duration: 300, easing: 'ease-out'},
    )
  }}
>
  <Component />
</ViewTransition>

Combined with the Web Animations API, dynamic animations that are difficult to express in CSS become possible.

View Transition Pseudo-Element Structure

When a View Transition activates, the browser creates the following pseudo-element tree. Understanding this structure is necessary for CSS customization.

::view-transition
โ””โ”€โ”€ ::view-transition-group(name)
    โ””โ”€โ”€ ::view-transition-image-pair(name)
        โ”œโ”€โ”€ ::view-transition-old(name)    โ† Pre-transition snapshot (image)
        โ””โ”€โ”€ ::view-transition-new(name)    โ† Post-transition live representation
  • ::view-transition-old: A static snapshot of the before state. Default animation: opacity: 1 โ†’ 0.
  • ::view-transition-new: A live representation of the after state. Default animation: opacity: 0 โ†’ 1.
  • ::view-transition-group: A container wrapping old and new, handling position and size transitions.

In React, CSS classes passed as <ViewTransition> props become selectors for these pseudo-elements.

<ViewTransition enter="slide-in">
  <Component />
</ViewTransition>

This activates selectors like ::view-transition-old(.slide-in) and ::view-transition-new(.slide-in) on enter.

Four Activation Types

<ViewTransition> activates in four types depending on the situation. React automatically determines the nature of the DOM change and decides which type to activate.

TypeDescriptionExample
enterWhen a component mounts during a TransitionNew element via conditional rendering
exitWhen a component unmounts during a TransitionElement removal, page transition
updateWhen internal DOM changes or layout movesProps change, list reordering
shareWhen an element with the same name disappears on one side and appears on the otherSame element transition between pages

Individual CSS classes can be specified for each type.

<ViewTransition enter="slide-in" exit="slide-out" update="cross-fade">
  <Component />
</ViewTransition>

The default prop sets a fallback for unspecified types. It accepts either a string or an object.

// String: same class for all types
<ViewTransition default="fade" enter="slide-up">
  <Component />
</ViewTransition>

Here, enter uses slide-up, while the rest (exit, update, share) use fade.

// Object: different classes mapped by transition type
<ViewTransition
  default={{
    'nav-forward': 'slide-left',
    'nav-back': 'slide-right',
    default: 'fade',
  }}
>
  <Component />
</ViewTransition>

In object form, the keys are type strings specified via addTransitionType and the values are CSS class names. The default key is the fallback when no type matches. This pattern is explored in detail in the directional slide example below.

Practical Example 1: Enter/Exit Animation

This is the most basic usage โ€” applying animations when elements appear and disappear, or when pages transition.

A toggle panel:

import {useState, startTransition, ViewTransition} from 'react'

function TogglePanel() {
  const [show, setShow] = useState(false)

  return (
    <div>
      <button onClick={() => startTransition(() => setShow(!show))}>
        {show ? 'Close' : 'Open'}
      </button>
      {show && (
        <ViewTransition enter="slide-up" exit="slide-down">
          <div className="panel">
            <h3>Panel Content</h3>
            <p>This panel appears and disappears with animation.</p>
          </div>
        </ViewTransition>
      )}
    </div>
  )
}
::view-transition-new(.slide-up) {
  animation:
    300ms ease-out slide-in-up,
    300ms ease-out fade-in;
}

::view-transition-old(.slide-down) {
  animation:
    200ms ease-in slide-out-down,
    200ms ease-in fade-out;
}

@keyframes slide-in-up {
  from {
    transform: translateY(20px);
  }
  to {
    transform: translateY(0);
  }
}

@keyframes slide-out-down {
  from {
    transform: translateY(0);
  }
  to {
    transform: translateY(20px);
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
}

@keyframes fade-out {
  to {
    opacity: 0;
  }
}

Clicking "Open" slides the panel up from below with a fade-in, and clicking "Close" slides it down with a fade-out. Calling just setShow(!show) without startTransition shows and hides the panel instantly without animation.

The demo recreated with the native View Transition API shows the actual behavior. (Open in Chrome/Edge)

Page transitions follow the same pattern. If the router uses startTransition internally, one <ViewTransition> is enough.

function App() {
  const {url} = useRouter()

  return <ViewTransition>{url === '/' ? <Home /> : <Details />}</ViewTransition>
}

This alone applies a cross-fade where the previous page gradually disappears and the new page gradually appears during transitions.

Practical Example 2: Shared Element Transition

You can create animations where the same element moves naturally between two pages. This is similar to iOS Hero Animations or Android Shared Element Transitions.

The core principle is simple. When a <ViewTransition> with the same name prop unmounts on one side and mounts on the other, React recognizes this as a transition of the same element.

List page:

function VideoList({videos}) {
  return (
    <div className="grid">
      {videos.map((video) => (
        <Link key={video.id} href={`/video/${video.id}`}>
          <ViewTransition name={`video-${video.id}`}>
            <img src={video.thumbnail} alt={video.title} />
          </ViewTransition>
          <ViewTransition name={`title-${video.id}`}>
            <h3>{video.title}</h3>
          </ViewTransition>
        </Link>
      ))}
    </div>
  )
}

Detail page:

function VideoDetail({video}) {
  return (
    <div>
      <ViewTransition name={`video-${video.id}`}>
        <video src={video.url} controls />
      </ViewTransition>
      <ViewTransition name={`title-${video.id}`}>
        <h1>{video.title}</h1>
      </ViewTransition>
      <p>{video.description}</p>
    </div>
  )
}

When navigating from list to detail, the small thumbnail in the grid scales up and moves to the large video player position on the detail page, while the small h3 title naturally transitions to the larger h1 position. Position, size, and shape interpolation are all handled automatically without writing a single line of CSS. Pressing back plays the same animation in reverse.

In the demo, click a card to see the image and title expand and move to the detail view.

In real apps, shared elements are often combined with directional slides. By wrapping the content area with <ViewTransition> in the Layout, images move as shared elements while the rest of the content transitions with slides. Both animations run simultaneously, creating a native app-like experience.

function Layout({children}) {
  return (
    <div>
      <Header />
      <ViewTransition
        default={{
          'nav-forward': 'slide-left',
          'nav-back': 'slide-right',
          default: 'fade',
        }}
      >
        <main>{children}</main>
      </ViewTransition>
    </div>
  )
}

Important notes:

  • Only one <ViewTransition> with the same name should be mounted at a time. Having two or more with the same name causes an error.
  • Both elements must be within the viewport for the shared transition to form.

Practical Example 3: Slide Animation Based on Navigation Direction

This pattern slides in different directions for back and forward navigation, using the addTransitionType API.

import {startTransition, addTransitionType, ViewTransition} from 'react'

function useNavigate() {
  const router = useRouter()

  return {
    forward(url: string) {
      startTransition(() => {
        addTransitionType('nav-forward')
        router.push(url)
      })
    },
    back() {
      startTransition(() => {
        addTransitionType('nav-back')
        router.back()
      })
    },
  }
}

Map different classes for each type in <ViewTransition>.

function App() {
  const {url} = useRouter()

  return (
    <ViewTransition
      default={{
        'nav-forward': 'slide-left',
        'nav-back': 'slide-right',
        default: 'fade',
      }}
    >
      <Page url={url} />
    </ViewTransition>
  )
}

Define the CSS animations.

/* Forward: current page slides out left, new page enters from right */
::view-transition-old(.slide-left) {
  animation:
    150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(.slide-left) {
  animation:
    210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Back: current page slides out right, previous page enters from left */
::view-transition-old(.slide-right) {
  animation:
    150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
}

::view-transition-new(.slide-right) {
  animation:
    210ms cubic-bezier(0, 0, 0.2, 1) 150ms both fade-in,
    400ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
}

@keyframes slide-to-left {
  to {
    transform: translateX(-50px);
  }
}

@keyframes slide-from-right {
  from {
    transform: translateX(50px);
  }
}

@keyframes slide-to-right {
  to {
    transform: translateX(50px);
  }
}

@keyframes slide-from-left {
  from {
    transform: translateX(-50px);
  }
}

The result: pressing "forward" slides the current page out to the left while the new page slides in from the right, and pressing "back" reverses the direction. This provides the same visual experience as a native app's navigation stack.

Try the demo โ€” move between the top tabs left and right to see directional slides.

addTransitionType can be called multiple times within a single startTransition callback. Types are simple strings that match as keys in the <ViewTransition> prop object.

Practical Example 4: List Animation

Using useDeferredValue makes list items naturally appear and disappear during search and filtering.

import {useState, useDeferredValue, ViewTransition} from 'react'

function FilterableList({items}) {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)

  const filtered = items.filter((item) =>
    item.name.toLowerCase().includes(deferredQuery.toLowerCase()),
  )

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {filtered.map((item) => (
          <ViewTransition key={item.id}>
            <li>{item.name}</li>
          </ViewTransition>
        ))}
      </ul>
    </div>
  )
}

Since useDeferredValue automatically creates Transitions, there's no need to explicitly call startTransition. As you type a search query, filtered-out items cross-fade away and the remaining items naturally rearrange their positions. Clearing the search query brings hidden items back with a fade-in.

Sorting follows the same pattern. Changing the sort criteria with startTransition automatically applies movement animations as each item shifts to its new position.

<button onClick={() => startTransition(() => setSortBy('date'))}>Recent</button>

Try the demo โ€” use search and sort buttons to see item rearrangement animations.

Note: The direct child of <ViewTransition> must be a DOM element. Having other component wrappers in between may prevent animations from working.

Practical Example 5: Suspense Integration

Animations also apply when transitioning from a <Suspense> fallback to actual content. There are two placement methods with different results.

Method 1: Wrap from Outside (works as update)

<ViewTransition>
  <Suspense fallback={<Skeleton />}>
    <Content />
  </Suspense>
</ViewTransition>

The transition from Skeleton to Content is treated as a single update. Visually, the skeleton gradually fades out while the actual content appears in the same position โ€” a cross-fade effect.

Method 2: Wrap Each Separately (works as enter/exit)

<Suspense
  fallback={
    <ViewTransition exit="slide-down">
      <Skeleton />
    </ViewTransition>
  }
>
  <ViewTransition enter="slide-up">
    <Content />
  </ViewTransition>
</Suspense>
::view-transition-old(.slide-down) {
  animation:
    150ms ease-out fade-out,
    150ms ease-out slide-out-down;
}

::view-transition-new(.slide-up) {
  animation:
    210ms ease-in 150ms fade-in,
    400ms ease-in slide-in-up;
}

@keyframes slide-out-down {
  from {
    transform: translateY(0);
  }
  to {
    transform: translateY(10px);
  }
}

@keyframes slide-in-up {
  from {
    transform: translateY(10px);
  }
  to {
    transform: translateY(0);
  }
}

In this case, the skeleton slides down slightly while disappearing, and after a brief delay, the actual content slides up from below. Separate control over enter and exit allows for more detailed choreography.

Compare both methods side by side in the demo. Press both "Load Data" buttons simultaneously to see the clear difference.

Both methods wait for React to finish loading data, CSS, and fonts before starting the animation.

Using with Next.js

In Next.js, enable the experimental viewTransition flag.

// next.config.ts
import type {NextConfig} from 'next'

const nextConfig: NextConfig = {
  experimental: {
    viewTransition: true,
  },
}

export default nextConfig

With this flag enabled, you can import ViewTransition from react without the unstable_ prefix. Features like automatically adding transition types to Next.js navigation (e.g., automatically connecting forward/back directions to addTransitionType) have not yet been implemented as of February 2026. For now, you need to call addTransitionType directly as shown in the examples above.

Next.js's Link component uses startTransition internally, so page transition animations work right away just by wrapping with <ViewTransition>.

// app/layout.tsx
import {ViewTransition} from 'react'

export default function RootLayout({children}) {
  return (
    <html>
      <body>
        <Nav />
        <ViewTransition>{children}</ViewTransition>
      </body>
    </html>
  )
}

Check out the Next.js View Transition Demo for a working example.

Caveats

<ViewTransition> Is Not a Solution for All Animations

This is a point the React team has made clear. React's <ViewTransition> is specialized for UI transitions driven by React state changes. While the browser's View Transition API itself can be used more broadly (covered below), the React component has clear boundaries:

  • โœ… Page navigation, modal open/close, list reordering, accordion expansion
  • โŒ Like button heart animation, loading shimmer, typing effects, interactive drag

For the latter, continue using CSS animation/transition or libraries like Framer Motion.

You Can Use View Transitions Without State Changes

React's <ViewTransition> only triggers on React state changes (startTransition, useDeferredValue, Suspense). This is because React needs to compare DOM snapshots before and after rendering. So if you want "just an animation without changing state," <ViewTransition> isn't the right tool.

However, the browser's native document.startViewTransition() can be used without any constraints. You can wrap any DOM change โ€” toggling a class, changing inline styles, or letting an external library manipulate the DOM โ€” regardless of React state.

A great example is theme toggling. Dark/light mode switching typically toggles a class on the <html> element. Since this is direct DOM manipulation rather than a React state change, <ViewTransition> can't animate it. Use the native API instead:

function toggleTheme(e: React.MouseEvent) {
  const x = e.clientX
  const y = e.clientY

  document.startViewTransition(() => {
    // Direct DOM manipulation, not React state
    document.documentElement.classList.toggle('dark')
  })

  // Set CSS variables for circle-clip animation origin
  document.documentElement.style.setProperty('--theme-toggle-x', `${x}px`)
  document.documentElement.style.setProperty('--theme-toggle-y', `${y}px`)
}
/* Circle-expand animation from click position on theme toggle */
.theme-transition-circle::view-transition-new(root) {
  animation: circle-clip 0.5s ease-in-out;
}

@keyframes circle-clip {
  from {
    clip-path: circle(0% at var(--theme-toggle-x) var(--theme-toggle-y));
  }
  to {
    clip-path: circle(150% at var(--theme-toggle-x) var(--theme-toggle-y));
  }
}

In summary:

ScenarioAPI to Use
UI transitions driven by React state<ViewTransition>
DOM manipulation outside React (theme toggle, external libs)document.startViewTransition()
Suspense fallback โ†’ actual content<ViewTransition>
Scroll-based animations, mouse tracking, etc.CSS animation/transition

The two APIs are not mutually exclusive. It's perfectly natural to use <ViewTransition> for page transitions and document.startViewTransition() for theme toggles in the same app. In fact, this very blog does exactly that.

prefers-reduced-motion Must Be Handled Manually

Browser accessibility settings are not automatically respected.

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Must Directly Wrap DOM Nodes

<ViewTransition> targets the first DOM node inside it. Wrapping only text or using it without DOM nodes won't work.

// โŒ Won't work
<ViewTransition>
  Just text
</ViewTransition>

// โœ… Works
<ViewTransition>
  <span>Text must be wrapped</span>
</ViewTransition>

Only One of the Same Name at a Time

// โŒ Will error
<ViewTransition name="hero"><img src="a.jpg" /></ViewTransition>
<ViewTransition name="hero"><img src="b.jpg" /></ViewTransition>

// โœ… Use unique names
<ViewTransition name={`hero-${id}`}><img src="a.jpg" /></ViewTransition>

Use "none" to Exclude Parts from Animation

Use this when you've applied <ViewTransition> to a parent but want to exclude expensive-to-render children.

<ViewTransition>
  <div className="dashboard">
    <Header />
    <ViewTransition update="none">
      <HeavyChart data={chartData} />
    </ViewTransition>
    <Sidebar />
  </div>
</ViewTransition>

Browser Support

BrowserSame-documentCross-document
Chromeโœ…โœ… (126+)
Edgeโœ…โœ… (126+)
Safariโœ… (18+)โŒ
FirefoxโŒโŒ

The lack of Firefox support is disappointing, but browsers that don't support the View Transition API simply transition immediately without animation โ€” functionality doesn't break. Approach it as progressive enhancement.

Conclusion

We've explored why a dedicated component is needed to use the View Transition API in React, and how to use it. Compared to SvelteKit or Angular finishing with a single configuration line, it's clearly a heavier approach. But that weight translates into React-specific advantages like Suspense integration and automatic useDeferredValue connections, which makes it a reasonable trade-off.

As of March 2026, it's only available in the canary channel with no announced stable release schedule. Browser support also lacks Firefox. Rather than rushing into production, getting familiar with CSS View Transition pseudo-selector syntax and addTransitionType patterns will prepare you for quick adoption when the official release arrives.

References