A navigation here is an instant swap: the layouts stay mounted and the page underneath changes, the way Next’s router does it. Nothing fades, and nothing needs to — there is no document reload to hide. Most apps should stop reading here.
React 19.3 ships <ViewTransition>,
which animates a change by handing it to the browser’s View Transition API,
and you can use it here for the one thing it is good at: a shared element
— a product image that morphs from the list into the detail page, a card
that grows into a panel. A fade over the whole page is a different thing.
It is cosmetic, it never makes a navigation faster, and on WebKit — Safari,
and every browser on iOS, Chrome included — it is expensive in a way a
desktop never shows; a port measured 600–900 ms with the screen frozen on
every navigation away from a long landing page before the transition was
taken out. If you want it anyway, the last section says how to have it
without paying that.
<ViewTransition> has one boundary worth knowing before you reach for it,
because the failure is silence: a transition that does not run looks exactly
like one you styled badly.
| the change comes from | animates |
|---|---|
your own useState, inside startTransition |
yes |
| a server action’s result, put into state | yes |
<Form> — errors, success, optimistic updates |
yes |
a streamed <Suspense> boundary arriving |
yes, React does this on its own |
| navigating to another page | yes — the update carries a transition type |
What works
Ordinary React. Give the changing element a key so React sees a replacement
rather than an edit, and commit inside startTransition:
'use client';
import { useState, startTransition, ViewTransition } from 'react';
import { addToTotal } from '../actions';
export function Total() {
const [total, setTotal] = useState<number | null>(null);
return (
<>
<ViewTransition>
<p key={String(total)}>{total ?? '—'}</p>
</ViewTransition>
<button
onClick={() =>
startTransition(async () => {
const next = await addToTotal(1);
startTransition(() => setTotal(next));
})
}
>
add
</button>
</>
);
}A server action is in the working column for a reason worth stating: its result
comes back as an ordinary return value, and what you do with it is useState.
That makes it a transition like any other. <Form> is built on useState
throughout, so everything it drives animates the same way.
A shared element across pages
A navigation replaces the segment inside startTransition, so a
<ViewTransition> inside it animates like any other. Name the element that
should carry across — the same name on the list page and the detail page —
and the browser morphs it from where it was to where it is:
<ViewTransition name={`product-${product.id}`}>
<img src={product.image} alt="" />
</ViewTransition>One element, one snapshot the size of that element: cheap on every engine. This is the transition worth having.
The page fade, if you want it
What you cannot tell from outside is a navigation from the first commit
after hydration — the boundary taking over the server-rendered page, which
animated would be the page fading into itself on every load. So a
navigation’s update carries the transition type rsc-navigation, and that
first commit does not. PageTransition keys the boundary on it, and skips
the fade where it is expensive:
import { PageTransition } from '@rsc-kit/core/PageTransition';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className="min-h-full flex flex-col">
<PageTransition className="flex flex-1 flex-col">{children}</PageTransition>
</body>
</html>
);
}PageTransition is <ViewTransition default={{ 'rsc-navigation': 'page', default: 'none' }}>
around one <div>, with its CSS — there is nothing to add to your
stylesheet. On WebKit the class is 'none', decided after mount — see the
caution below for why — and so it is for a visitor who asked for reduced
motion. default: 'none' is what makes a first load or a reload never
animate. The <div> matters (here it also carries the body’s flex layout,
so a <main> inside still fills the height): React names each top-level
element under a boundary and animates every one on its own, so a page of
five sections becomes five groups, each morphing from where it was to
where it is now. One wrapper element is one snapshot pair — the old page
and the new one dissolving into each other, the way Inertia’s does. A
navbar inside the pair cross-fades into itself, which is invisible; put the
boundary around <main> in a nested layout instead when you want the
navbar left out of it entirely. Write the ViewTransition yourself,
without the wrapper, when you want the per-element movement.
Shaping the animation
The component’s CSS is the part that is easy to get wrong by hand, so it
ships with it: a 120 ms cross-fade, and ::view-transition { pointer-events: none }, because the pseudo-element tree sits over the document while the
fade runs and would otherwise swallow a second tap. The browser’s default
is 250 ms, and the group and root pairs keep it whatever you set on the
page pair — a “120 ms” fade written as the two-selector rule most examples
show ran 290 ms. Two props adjust it:
<PageTransition duration={200}>{children}</PageTransition> // ms; 0 is the instant swap
<PageTransition webkit="run">{children}</PageTransition> // a short page that may run it on WebKitduration={0} is the instant swap with the transition still in place for
the shared elements you name yourself (<ViewTransition name="hero"> still
morphs). webkit="run" is for a page you have measured — a form, a settings
page — where the snapshot is one screen; the default skips WebKit for the
reason below. To shape the fade further, style ::view-transition-old(.page)
and ::view-transition-new(.page) yourself (the transition prop renames
the class); your rules come after the component’s.
Pages that ship no JavaScript
A page with nothing interactive on it ships no runtime, so a link from it is
an ordinary page load - there is no router to run <ViewTransition>. The
browser can still animate that load on its own, with one rule of CSS and no
JavaScript at all:
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
}With it on both pages, a same-site link animates from one document to the
next - a crossfade by default, shaped with the same ::view-transition-*
selectors as above, and view-transition-name on an element on both pages
for a shared one. Chrome, Edge and Safari 18.2+ do it; elsewhere the link is
the page load it always was.
The same caution as the page fade applies: it never makes a navigation faster, and on WebKit a large page is expensive to snapshot. On pages that do ship the runtime it has no effect - their navigations are the router’s, not page loads.
Coming back to a page you were just on
A navigation to a page still being held reveals it rather than refetching it, so the form you were filling in is still filled in. That applies to a link, not only the back button — having one keep your work and the other throw it away is a distinction nobody makes while using an app.
It is bounded, because the two halves pull against each other: what comes back is the tree from when you left, so its data is from then. Thirty seconds by default, which covers leaving a form to check something and coming straight back. Past that a link refetches.
import { setRevealWindow } from '@rsc-kit/core/navigate';
setRevealWindow(0); // never reveal — every link is a fresh requestThe back button is not bounded. It names a moment, and the page from that moment is the right answer however old it is.
Requirements
react and react-dom at 19.3 or newer. A scaffolded app pins ^19.2, so
this is an upgrade:
npm install react@^19.3 react-dom@^19.3React also documents a browser
api for rendering a component only in the browser — the right tool for a value
the server cannot know. It is not in 19.3 yet; see
static generation
for what to do meanwhile.