docs/oss/building-features/view-transitions.md
⚠️ Experimental — not officially supported. This entire page is a canary recipe. The browser View Transitions API is still evolving, React's component-level
<ViewTransition>API is canary-only, and this recipe may change or break without notice. Nothing on this page is covered by React on Rails' support policy or semver guarantees. React Server Components are explicitly out of scope here. Tracked in #3888.
This page shows how to use the browser-native document.startViewTransition() API with components
rendered by react_component — first client-side-only (CSR), then with server rendering plus hydration
(SSR-hydrate) — and how it interacts with Turbo Drive's own view-transition support.
document.startViewTransition().It deliberately does not cover React's canary <ViewTransition> component / addTransitionType
API beyond a status note (see below), and it does not cover React Server Components.
Same-document view transitions (document.startViewTransition) ship in Chrome/Edge 111+, Safari 18+,
and recent Firefox releases, but you must always feature-detect — unsupported browsers simply apply the
DOM update without animation:
function withViewTransition(applyUpdate) {
if (typeof document.startViewTransition === 'function') {
document.startViewTransition(applyUpdate);
} else {
applyUpdate(); // graceful fallback: same update, no animation
}
}
document.startViewTransition(callback) snapshots the current page, runs your callback to mutate the
DOM, snapshots the result, and animates between the two snapshots.
The catch for React: React 18/19 batches state updates and commits asynchronously, but the browser
expects the DOM to be fully updated when your callback returns. Wrap the state update in
flushSync so React commits synchronously inside
the callback:
import React, { useState } from 'react';
import { flushSync } from 'react-dom';
const Panel = () => {
const [expanded, setExpanded] = useState(false);
const toggle = () => {
const applyUpdate = () => {
flushSync(() => {
setExpanded((prev) => !prev);
});
};
if (typeof document.startViewTransition === 'function') {
document.startViewTransition(applyUpdate);
} else {
applyUpdate();
}
};
return (
<div>
<button type="button" onClick={toggle}>
Toggle
</button>
<div id="vt-panel" className={expanded ? 'panel panel--expanded' : 'panel'}>
…
</div>
</div>
);
};
Give the animated element a view-transition-name so the browser can pair its old/new snapshots, and
optionally style the transition pseudo-elements:
#vt-panel {
view-transition-name: vt-panel;
}
::view-transition-old(vt-panel),
::view-transition-new(vt-panel) {
animation-duration: 300ms;
}
This works with plain react_component("Panel", prerender: false) — no React on Rails configuration is
involved; the recipe is entirely inside your component and CSS.
The same pattern wraps client-side navigation inside a React root: call your router's navigate function
inside the startViewTransition callback (with flushSync if the router commits asynchronously). If
you use React Router, prefer its built-in integration (<Link viewTransition> /
useViewTransitionState) instead of hand-rolling — see React Router. These
router-level integrations are experimental in the same sense as the rest of this page.
flushSync is a forced synchronous render. Keep the transitioned update small; large trees will
jank inside the transition callback.view-transition-names abort the transition. Each name must be unique on the page at
snapshot time (watch out for lists; derive names from item ids if you animate list items).view-transition-name cannot start with a digit
and cannot be a CSS-wide keyword (none, inherit, …) — prefix derived names, e.g. item-42, not
42.The same component works with prerender: true (server rendering + client hydration) as long as you
follow these rules:
document does not exist in the server-side
rendering environment (ExecJS or the Pro Node renderer). Never call or feature-detect
document.startViewTransition at module scope or during render of a server-rendered component.useEffect after mount.view-transition-name in server-rendered HTML is safe. It is plain CSS — it has no effect until
a transition starts, and it does not affect hydration.In short: SSR-hydrate works today with the browser API because the transition only ever runs on the client, after hydration, inside user-triggered handlers.
<ViewTransition> status (Experimental)React's component-level API (<ViewTransition>, addTransitionType) is canary-only and is not
part of this recipe. React 19.2 shipped groundwork that matters for the future here — SSR Suspense
reveal batching, and the useId prefix change to _r_ so generated ids are valid
view-transition-name values — but running a canary React build under React on Rails is untested and
unsupported. This page intentionally uses only the browser API, which works on stable React 19.
⚠️ Turbo claims pending maintainer review. The claims in this section follow Turbo's documented behavior and React on Rails' Turbo event wiring, but they have not yet been verified end-to-end in the dummy app. Treat them as provisional until a maintainer confirms them.
Rails apps using Turbo Drive have a second, independent view-transition
system: Turbo can wrap its cross-page (page-to-page) renders in document.startViewTransition when
the page opts in via:
<meta name="view-transition" content="same-origin" />
How this relates to React-driven transitions:
<body>); React-driven transitions (this page's recipe) animate state/route updates inside a
mounted React root. Use Turbo's mechanism for cross-page continuity and the React recipe for in-page
interactions — they solve different problems.turbo:before-render /
turbo:render and unmounts components before Turbo swaps the body, then re-mounts them on the new
page (see packages/react-on-rails/src/pageLifecycle.ts). Any cross-page visual continuity must come
from Turbo's transition (matching view-transition-names in the old and new HTML), not from React.startViewTransition from code
that runs during a Turbo visit (e.g., in turbo:before-render/turbo:render handlers or unmount
paths). In practice this is easy to satisfy: trigger React transitions only from user interactions.view-transition-name that appear in both the old
page and the preview can pair unexpectedly or abort (duplicate names). Test with the cache in play
before shipping, or disable preview for transitioned pages
(<meta name="turbo-cache-control" content="no-preview">).The gem's dummy app contains a minimal CSR demo of this recipe, inert unless you opt in with an environment flag:
react_on_rails/spec/dummy/client/app/startup/ViewTransitionsDemo.client.tsxreact_on_rails/spec/dummy/app/views/pages/view_transitions_demo.html.erbreact_on_rails/spec/dummy/config/routes.rb only when VIEW_TRANSITIONS_DEMO=trueRun it from a React on Rails checkout:
cd react_on_rails/spec/dummy
VIEW_TRANSITIONS_DEMO=true bin/dev
# then open http://localhost:3000/view_transitions_demo
With the flag unset, the route does not exist and the demo page is unreachable.
This page graduates from experimental to supported only when all of the following are true:
<ViewTransition> / addTransitionType ship in a stable React release, and React on
Rails' supported React range includes that release.Owner: maintainers — tracked in #3888. Re-check cadence: each React minor release (or quarterly, whichever comes first).