UNPKG

@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
# 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 '@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: ```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 '@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. <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.