React View Transitions
React's View Transition API lets you animate between UI states using the browser's native
document.startViewTransition
under the hood. Declare
what to animate with
, trigger
when with
/
/
, and control
how with CSS classes or the Web Animations API. Unsupported browsers skip the animation and apply the DOM change instantly.
When to Animate (and When Not To)
Every
should answer:
what spatial relationship or continuity does this animation communicate to the user? If you can't articulate it, don't add it.
Hierarchy of Animation Intent
From highest value to lowest — start from the top and only move down if your app doesn't already have animations at that level:
| Priority | Pattern | What it communicates | Example |
|---|
| 1 | Shared element () | "This is the same thing — I'm going deeper" | List thumbnail morphs into detail hero |
| 2 | Suspense reveal | "Data loaded, here's the real content" | Skeleton cross-fades into loaded page |
| 3 | List identity (per-item ) | "Same items, new arrangement" | Cards reorder during sort/filter |
| 4 | State change (/) | "Something appeared or disappeared" | Panel slides in on toggle |
| 5 | Route change (layout-level) | "Going to a new place" | Cross-fade between pages |
Route-level transitions (#5) are the lowest priority because the URL change already signals a context switch. A blanket cross-fade on every navigation says nothing — it's visual noise. Prefer specific, intentional animations (#1–#4) over ambient page transitions.
Rule of thumb: at any given moment, only one level of the tree should be visually transitioning. If your pages already manage their own Suspense reveals or shared element morphs, adding a layout-level route transition on top produces double-animation where both levels fight for attention.
Choosing the Right Animation Style
Not everything should slide. Match the animation to the spatial relationship:
| Context | Animation | Why |
|---|
| Detail page main content | | Reveals "deeper" content the user drilled into |
| Detail page outer wrapper | / type map for | Navigating forward — horizontal direction |
| List / overview pages | Bare (fade) or | Lateral navigation — no spatial depth to communicate |
| Page headers / breadcrumbs | Bare (fade) | Small, fast-loading metadata — slide feels excessive |
| Secondary section on same page | | Second Suspense boundary streaming in after the header |
| Revalidation / background refresh | | Data refreshed silently — animation would be distracting |
When in doubt, use a bare
(default cross-fade) or
. Only add directional motion (slide-up, slide-from-right) when it communicates spatial meaning.
Availability
- and require or . They are not in stable React (including 19.x). Before implementing, verify the project uses canary — check for or run . If on stable, install canary:
npm install react@canary react-dom@canary
.
- Browser support: Chromium 111+, with Firefox and Safari adding support. The API gracefully degrades — unsupported browsers skip the animation and apply the DOM change instantly.
Core Concepts
The Component
Wrap the elements you want to animate:
jsx
import { ViewTransition } from 'react';
<ViewTransition>
<Component />
</ViewTransition>
React automatically assigns a unique
to the nearest DOM node inside each
, and calls
document.startViewTransition
behind the scenes. Never call
yourself — React coordinates all view transitions and will interrupt external ones.
Animation Triggers
React decides which type of animation to run based on what changed:
| Trigger | When it fires |
|---|
| enter | A is first inserted during a Transition |
| exit | A is first removed during a Transition |
| update | DOM mutations happen inside a , or the boundary changes size/position due to an immediate sibling |
| share | A named unmounts and another with the same mounts in the same Transition (shared element transition) |
Only updates wrapped in
,
, or
activate
. Regular
updates immediately and does not animate.
Critical Placement Rule
only activates enter/exit if it appears
before any DOM nodes in the component tree:
jsx
// Works — ViewTransition is before the DOM node
function Item() {
return (
<ViewTransition enter="auto" exit="auto">
<div>Content</div>
</ViewTransition>
);
}
// Broken — a <div> wraps the ViewTransition, preventing enter/exit
function Item() {
return (
<div>
<ViewTransition enter="auto" exit="auto">
<div>Content</div>
</ViewTransition>
</div>
);
}
Styling Animations with View Transition Classes
Props
Each prop controls a different animation trigger. Values can be:
- — use the browser default cross-fade
- — disable this animation type
- — a custom CSS class
- An object
{ [transitionType]: value }
for type-specific animations (see Transition Types below)
jsx
<ViewTransition
default="none" // disable everything not explicitly listed
enter="slide-in" // CSS class for enter animations
exit="slide-out" // CSS class for exit animations
update="cross-fade" // CSS class for update animations
share="morph" // CSS class for shared element animations
/>
If
is
, all triggers are off unless explicitly listed.
Defining CSS Animations
Use the view transition pseudo-element selectors with the class name:
css
::view-transition-old(.slide-in) {
animation: 300ms ease-out slide-out-to-left;
}
::view-transition-new(.slide-in) {
animation: 300ms ease-out slide-in-from-right;
}
@keyframes slide-out-to-left {
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in-from-right {
from { transform: translateX(100%); opacity: 0; }
}
The pseudo-elements available are:
::view-transition-group(.class)
— the container for the transition
::view-transition-image-pair(.class)
— contains old and new snapshots
::view-transition-old(.class)
— the outgoing snapshot
::view-transition-new(.class)
— the incoming snapshot
Transition Types with
lets you tag a transition with a string label so
can pick different animations based on
what caused the change. This is essential for directional navigation (forward vs. back) or distinguishing user actions (click vs. swipe vs. keyboard).
Basic Usage
jsx
import { startTransition, addTransitionType } from 'react';
function navigate(url, direction) {
startTransition(() => {
addTransitionType(`navigation-${direction}`); // "navigation-forward" or "navigation-back"
setCurrentPage(url);
});
}
You can add multiple types to a single transition, and if multiple transitions are batched, all types are collected.
Using Types with View Transition Classes
Pass an object instead of a string to any activation prop. Keys are transition type strings, values are CSS class names:
jsx
<ViewTransition
enter={{
'navigation-forward': 'slide-in-from-right',
'navigation-back': 'slide-in-from-left',
default: 'fade-in',
}}
exit={{
'navigation-forward': 'slide-out-to-left',
'navigation-back': 'slide-out-to-right',
default: 'fade-out',
}}
>
<Page />
</ViewTransition>
The
key inside the object is the fallback when no type matches. If any type has the value
, the ViewTransition is disabled for that trigger.
Using Types with CSS :active-view-transition-type()
React adds transition types as browser view transition types, enabling pure CSS scoping with
:root:active-view-transition-type(type-name)
.
Caveat: /
match
all named elements — the wildcard can override specific class-based animations. Prefer class-based props for per-component animations; reserve
:active-view-transition-type()
for global rules.
The
array is also available as the second argument in event callbacks (
,
, etc.) — see
.
Types and Suspense: When Types Are Available
When a
with
triggers navigation, the transition type is available to
all s that enter/exit during that navigation. An outer page-level
with a type map sees the type and responds. Inner
s with simple string props also enter — the type is irrelevant to them because simple strings fire regardless of type.
Subsequent Suspense reveals — when streamed data loads after navigation completes — are separate transitions with no type. This means type-keyed props on Suspense content don't work:
jsx
// This does NOT animate on Suspense reveal — the type is gone by then
<ViewTransition enter={{ "nav-forward": "slide-up", default: "none" }} default="none">
<AsyncContent />
</ViewTransition>
When Suspense resolves later, a new transition fires with no type — so
applies and nothing animates.
Use type maps for s that enter/exit directly with the navigation. Use simple string props for Suspense reveals. See the two-layer pattern in "Two Patterns — Can Coexist with Proper Isolation" below for a complete example.
Shared Element Transitions
Assign the same
to two
components — one in the unmounting tree and one in the mounting tree — to animate between them as if they're the same element:
jsx
const HERO_IMAGE = 'hero-image';
function ListView({ onSelect }) {
return (
<ViewTransition name={HERO_IMAGE}>
<img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />
</ViewTransition>
);
}
function DetailView() {
return (
<ViewTransition name={HERO_IMAGE}>
<img src="/full.jpg" />
</ViewTransition>
);
}
Rules for shared element transitions:
- Only one with a given can be mounted at a time — use globally unique names (namespace with a prefix or module constant).
- The "share" trigger takes precedence over "enter"/"exit".
- If either side is outside the viewport, no pair forms and each side animates independently as enter/exit.
- Use a constant defined in a shared module to avoid name collisions.
View Transition Events (JavaScript Animations)
For imperative control with
,
,
,
callbacks and the
object (
,
,
,
,
), see
. Always return a cleanup function from event handlers. Only one event fires per
per Transition —
takes precedence over
/
.
Common Patterns
Animate Enter/Exit of a Component
Conditionally render the
itself — toggle with
:
jsx
{show && (
<ViewTransition enter="fade-in" exit="fade-out">
<Panel />
</ViewTransition>
)}
Animate List Reorder
Wrap each item (not a wrapper div) in
with a stable
:
jsx
{items.map(item => (
<ViewTransition key={item.id}>
<ItemCard item={item} />
</ViewTransition>
))}
Triggering the reorder inside
will smoothly animate each item to its new position. Avoid wrapper
s between the list and
— they block the reorder animation.
How it works: doesn't need async work to animate. The View Transition API captures a "before" snapshot of the DOM, then React applies the state update, and the API captures an "after" snapshot. As long as items change position between snapshots, the animation runs — even for purely synchronous local state changes like sorting.
Force Re-Enter with
Use a
prop on
to force an enter/exit animation when a value changes — even if the component itself doesn't unmount:
jsx
<ViewTransition key={searchParams.toString()} enter="slide-up" exit="slide-down" default="none">
<ResultsGrid results={results} />
</ViewTransition>
When the key changes, React unmounts and remounts the
, which triggers exit on the old instance and enter on the new one. This is useful for animating content swaps driven by URL parameters, tab switches, or any state change where the content identity changes but the component type stays the same.
Caution with Suspense: If the
wraps a
, changing the key remounts the entire Suspense boundary, re-triggering the data fetch. Only use
on
outside of Suspense, or accept the refetch.
Animate Suspense Fallback to Content
The simplest approach: wrap
in a single
for a zero-config cross-fade from skeleton to content:
jsx
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Content />
</Suspense>
</ViewTransition>
For directional motion, give the fallback and content separate
s. Use
on the content to prevent re-animation on revalidation:
jsx
<Suspense
fallback={
<ViewTransition exit="slide-down">
<Skeleton />
</ViewTransition>
}
>
<ViewTransition default="none" enter="slide-up">
<AsyncContent />
</ViewTransition>
</Suspense>
Why on the fallback and on the content? When Suspense resolves, two things happen simultaneously in one transition: the fallback unmounts (exit) and the content mounts (enter). The fallback slides down and fades out while the content slides up and fades in — creating a smooth handoff. The staggered CSS timing (
delays by the
duration) ensures the skeleton leaves before new content arrives.
Opt Out of Nested Animations
Wrap children in
<ViewTransition update="none">
to prevent them from animating when a parent changes:
jsx
<ViewTransition>
<div className={theme}>
<ViewTransition update="none">
{children}
</ViewTransition>
</div>
</ViewTransition>
For more patterns (isolate persistent/floating elements, reusable animated collapse, preserve state with
, exclude elements with
), see
.
How Multiple s Interact
When a transition fires,
every in the tree that matches the trigger participates simultaneously. Each gets its own
, and the browser animates all of them inside a single
document.startViewTransition
call. They run in parallel, not sequentially.
This means multiple
s that fire during the
same transition all animate at once. A layout-level cross-fade + a page-level slide-up + per-item reorder all running in the same
document.startViewTransition
produces competing animations. But
s that fire in
different transitions (e.g., navigation vs. a later Suspense resolve) don't compete — they animate at different moments.
Use Liberally
Prevent unintended animations by disabling the default trigger on ViewTransitions that should only fire for specific types:
jsx
// Only animates when 'navigation-forward' or 'navigation-back' types are present.
// Silent on all other transitions (Suspense reveals, state changes, etc.)
<ViewTransition
default="none"
enter={{
'navigation-forward': 'slide-in-from-right',
'navigation-back': 'slide-in-from-left',
default: 'none',
}}
exit={{
'navigation-forward': 'slide-out-to-left',
'navigation-back': 'slide-out-to-right',
default: 'none',
}}
>
{children}
</ViewTransition>
TypeScript note: When passing an object to
/
, the
ViewTransitionClassPerType
type requires a
key. Always include
(or
) in the object — omitting it causes a type error even if the component-level
prop is set.
Without
, a
with
(the implicit default) fires the browser's cross-fade on
every transition — including ones triggered by child Suspense boundaries,
updates, or
calls within the page.
Next.js revalidation: This is especially important in Next.js — when
fires (from a Server Action, webhook, or polling), the page re-renders. Without
, every
in the tree re-animates: content slides up again, things flash. Always use
on content
s and only enable specific triggers (
,
) explicitly.
Two Patterns — Can Coexist with Proper Isolation
There are two distinct view transition patterns:
Pattern A — Directional page slides (e.g., left/right navigation):
- Uses on or to tag navigation direction
- An outer on the page maps types to slide classes with
- Fires during the navigation transition (when the type is present)
Pattern B — Suspense content reveals (e.g., streaming data):
- No needed
- Simple / on s around Suspense boundaries
- prevents re-animation on revalidation
- Fires later when data loads (a separate transition with no type)
These coexist when they fire at different moments. The nav slide fires during the navigation transition (with the type); the Suspense reveal fires later when data streams in (no type).
on both layers prevents cross-interference — the nav VT ignores Suspense resolves, and the Suspense VT ignores navigations:
jsx
<ViewTransition
enter={{ "nav-forward": "slide-from-right", default: "none" }}
exit={{ "nav-forward": "slide-to-left", default: "none" }}
default="none"
>
<div>
<Suspense fallback={
<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>
}>
<ViewTransition enter="slide-up" default="none">
<Content />
</ViewTransition>
</Suspense>
</div>
</ViewTransition>
Always pair with on directional transitions. Without an exit animation, the old page disappears instantly while the new one slides in at scroll position 0 — a jarring jump. The exit slide masks the scroll change within the transition snapshot because the old content animates out simultaneously.
When they DO conflict: If both layers use
, or if a layout-level
fires a cross-fade during the same transition as a page-level slide-up, they animate simultaneously and fight for attention. The conflict is about
same-moment animations, not about using both patterns on the same page.
Place the outer directional
in each
page component — not in a layout (layouts persist and don't trigger enter/exit). Per-page wrappers are the cleanest approach.
Shared element transitions (
prop) work alongside either pattern because the
trigger takes precedence over
/
.
Next.js Integration
Next.js supports React View Transitions.
works out of the box for
- and
-triggered updates — no config needed.
To also animate
navigations, enable the experimental flag in
(or
):
js
const nextConfig = {
experimental: {
viewTransition: true,
},
};
module.exports = nextConfig;
What this flag does: It wraps every
navigation in
document.startViewTransition
, so all mounted
components participate in every link click. Without this flag, only
/
-triggered transitions animate. This makes the composition rules in "How Multiple
s Interact" especially important: use
on layout-level
s to avoid competing animations.
For a detailed guide including App Router patterns and Server Component considerations, see
.
Key points:
- The component is imported from directly — no Next.js-specific import.
- Works with the App Router and + for programmatic navigation.
The prop on
supports a native
prop — pass an array of strings directly, no
or wrapper component needed:
tsx
<Link href="/products/1" transitionTypes={['transition-to-detail']}>View Product</Link>
For full examples with shared element transitions and directional animations, see
.
Accessibility
Always respect
. React does not disable animations automatically for this preference. Add this to your global CSS:
css
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}
Or disable specific animations conditionally in JavaScript events by checking the media query.
Reference Files
- — Real-world patterns (searchable grids, expand/collapse, type-safe helpers), animation timing, view transition events (JavaScript Animations API), and troubleshooting.
references/css-recipes.md
— Ready-to-use CSS animation recipes (slide, fade, scale, directional nav, and combined patterns).
- — Detailed Next.js integration guide with App Router patterns and Server Component considerations.