@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
216 lines (176 loc) • 9.56 kB
Markdown
# 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.
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>`](https://react.dev/reference/react/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`:
```tsx
'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:
```tsx
<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:
```tsx title="src/app/layout.tsx"
import { PageTransition } from '-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:
```tsx
<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.
<Aside type="caution" title="WebKit snapshots the whole page — which is why PageTransition skips it">
Safari — and every browser on iOS, Chrome included, since they all run
WebKit — rasterises the outgoing snapshot at the element's full height,
not the viewport's. A landing page 12,000 px tall is 44 megapixels at 3×:
a port measured ~600 ms with the screen frozen on every navigation away
from it, and the memory can be enough for iOS to discard the tab, which
comes back as a reload nobody asked for. Chromium caps the snapshot, so a
desktop or an Android phone never shows it. Other sites' transitions run
fine on the same phone because they name the document root, which the
spec clips to the viewport; React's `<ViewTransition>` names the element
it wraps. So `PageTransition` runs the cross-fade on Chromium and Firefox
and skips it where `navigator.vendor` is Apple's — decided after mount,
so the first render agrees with the server's. Writing the boundary
yourself, do the same, or put it on a container that is the viewport's
height and scrolls inside, so the snapshot is one screen.
</Aside>
## 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.
```ts
import { setRevealWindow } from '-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.
<Aside type="caution" title="It can hide a server that is down">
Revealing a held page fetches nothing, so those navigations keep working
when the backend does not. Measured with the process killed: a page visited
moments ago still navigates, one never visited does not. That is a reveal
window rather than offline support, and it means a dead server can go
unnoticed for as long as the window lasts — [`useOffline`](/guides/offline)
is what surfaces it. Actually surviving with no network is
[`offline: true`](/guides/offline#surviving-without-one), which is a service
worker and a different thing entirely.
</Aside>
## Requirements
`react` and `react-dom` at **19.3 or newer**. A scaffolded app pins `^19.2`, so
this is an upgrade:
```bash
npm install react@^19.3 react-dom@^19.3
```
React also documents a [`browser`](https://react.dev/reference/react-dom/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](/guides/static-generation#a-value-that-must-not-be-frozen)
for what to do meanwhile.