Skip to content

View transitions

Navigations are an instant swap; a shared element is the transition worth having, and the page fade is the one to think twice about.

Updated View as Markdown

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:

src/app/layout.tsxtsx
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 WebKit

duration={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:

src/app/styles.csscss
@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 request

The 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.3

React 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close