AniUI Academy

View Transitions

Using the browser View Transitions API through React 19.3's <ViewTransition> component — what actually animates, how the "old frame → new frame" model differs from mounting a CSS animation, naming shared elements across pages, and when it's progressive enhancement vs. a hard dependency.

10 min read

Before this lesson, every state change rendered instantly — React reconciled, the DOM updated, and the next frame reflected the new state. That's the right default for most interactions, but it means every "before → after" moment is visually a cut, not a dissolve. The browser's View Transitions API turns those cuts into animated transitions, and React 19.3's <ViewTransition> component is the declarative way to use it in a React tree.

The underlying model: snapshot, swap, animate

A view transition is conceptually three steps the browser performs for you:

  1. Snapshot the current DOM as a bitmap.
  2. Apply the DOM update your code made.
  3. Snapshot the new DOM as a bitmap, and animate from the old snapshot to the new one.

The default animation is a cross-fade between the two snapshots, over about 250ms. That alone is often enough — page navigations, filter applies, and modal opens all feel more coherent cross-faded than cut — but the real power is that the animation is pixel-level, so the before-DOM and after-DOM don't have to resemble each other at all. The browser is interpolating images, not CSS properties.

<ViewTransition> in React 19.3

The raw API is document.startViewTransition(callback). You'd call it inside an event handler, do your state update, and the browser would handle the snapshot-and-animate dance. That's awkward with React: setState is async, and you want the transition tied to the resulting render, not the moment the handler fires.

React 19.3 ships a <ViewTransition> component that handles that for you:

import { ViewTransition } from "react";
 
function Dashboard({ activeTab }) {
  return (
    <ViewTransition>
      {activeTab === "overview" ? <OverviewPanel /> : <DetailsPanel />}
    </ViewTransition>
  );
}

Any render inside <ViewTransition> that changes the rendered subtree — the ternary flipping, a list reordering, a route changing — runs inside a startViewTransition call automatically. The viewer sees the panel cross-fade from OverviewPanel to DetailsPanel, not a cut.

Nothing else about the component model changes. Props work the same; state works the same; effects fire in the same order. The boundary just decides "when a render under me changes what's on screen, animate it."

Naming shared elements: morphs, not cross-fades

A cross-fade is often fine. A morph — the same visual element appearing to move and resize between the two states — is sometimes much better. The View Transitions API supports this with a single CSS property:

.card-thumbnail {
  view-transition-name: card-cover;
}
 
.detail-hero {
  view-transition-name: card-cover;
}

When two snapshots contain elements sharing a view-transition-name, the browser pairs them up and animates between their positions and sizes instead of cross-fading. The thumbnail on a list page appears to grow and slide into the hero image on the detail page. The DOM node doesn't have to be the same node across renders — React can unmount one and mount the other — the name is what pairs them.

In React, that looks like:

function CardThumbnail({ card }) {
  return (
    <img
      src={card.thumbnail}
      style={{ viewTransitionName: `card-${card.id}` }}
      alt={card.title}
    />
  );
}
 
function CardHero({ card }) {
  return (
    <img
      src={card.hero}
      style={{ viewTransitionName: `card-${card.id}` }}
      alt={card.title}
    />
  );
}

The per-card id in the transition name matters — if every card uses the same name, the browser can't tell which pair to morph.

Styling the animation

The browser exposes the transition as a set of pseudo-elements on the :root of the document, which you style with CSS:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 180ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
 
::view-transition-group(card-cover) {
  animation-duration: 260ms;
}
  • ::view-transition-old(name) and ::view-transition-new(name) are the before and after snapshots of a named group.
  • ::view-transition-group(name) is the container the browser morphs.
  • root is the implicit group every unnamed element participates in.

For a first pass, the defaults look fine. Reach for the pseudo-elements when a specific transition is too fast, too slow, or needs easing that reads differently than the browser default.

Progressive enhancement is the whole adoption story

The View Transitions API is widely available in Chromium browsers and is rolling out in Safari and Firefox at different rates. Browsers that don't ship it skip the animation — the DOM update happens instantly, exactly as if <ViewTransition> weren't there. No feature-detect scaffolding is needed on your side.

That makes the feature safe to add incrementally. The only thing that can break is a page that depends on the transition — any logic that runs "after the animation finishes" won't fire, because there is no animation. Keep view transitions purely cosmetic and that failure mode doesn't exist.

Where it pays off and where it doesn't

View transitions earn their cost (one snapshot pair, one animation) at moments the viewer would otherwise notice as a cut:

  • Route changes — especially within react-router v7, where the outlet re-renders a different page's subtree.
  • Filter or tab applies on a list — the item set reorders smoothly instead of popping.
  • Modal and detail-view opens — pair the trigger card and the modal hero with a shared name and you get a native-app "zoom-in" feel.
  • Reorderable lists — move the dragged card's name into the destination slot and the browser handles the position morph.

They don't earn their cost for:

  • Hover micro-interactions — a tooltip firing forty times a minute shouldn't be running a snapshot pass each time. CSS transition: is made for that job and is cheaper.
  • High-frequency state — a progress bar updating every 100ms, typeahead search results filtering on every keystroke. The animation will fight the next state change.
  • Animations that need to interrupt cleanly mid-way — view transitions are discrete from start to finish; if the user acts again while the animation is playing, the next transition queues up rather than merging.

A worked example: list → detail morph

Try it yourself
Loading playground...

Click a card: the cover block appears to morph from its position in the grid to the detail view's hero, because the view-transition-name on both is the same (card-cover-${id}). The surrounding list cross-fades away and the detail body fades in. In a browser without the API, the same click still works — it just swaps instantly.

What to remember

  • The View Transitions API animates DOM changes by snapshotting the before and after and cross-fading between them. The browser does pixel-level interpolation, not CSS property interpolation.
  • React 19.3's <ViewTransition> wraps a subtree so any render inside the boundary that changes what's on screen runs inside startViewTransition automatically — no manual plumbing.
  • Shared view-transition-name CSS pairs elements across the before and after, turning a cross-fade into a morph. The name must be unique per pair (bake the item id into it).
  • Style the transition with ::view-transition-old(name), ::view-transition-new(name), and ::view-transition-group(name) pseudo-elements on :root.
  • Progressive enhancement: unsupported browsers skip the animation. Keep view transitions cosmetic; don't gate logic on the animation playing.
  • Use it for route changes, filter applies, modal opens, and list reorders — not hover micro-interactions or high-frequency state.

Check yourself

5 questions · pass 4/5 to unlock Routing and App Structure

up to 50
  1. 1.What does the browser's View Transitions API actually do when you wrap a DOM update in a view transition?

  2. 2.In React 19.3, what is the <ViewTransition> component for?

  3. 3.Why does naming two elements with the same view-transition-name (one on the "before" screen, one on the "after" screen) produce a morph rather than a cross-fade?

  4. 4.What's the responsible fallback story when a visitor's browser doesn't support the View Transitions API (older Safari, older Firefox at the time of a release)?

  5. 5.You wrap a hover-driven tooltip in <ViewTransition> and the page starts feeling janky. What's the realistic diagnosis?

5 left to answer

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.