View transitions
The browser View Transitions API animates between two DOM snapshots — a before image, a callback that mutates the DOM, an after image. JORVEL wraps navigation in document.startViewTransition()when available and falls back gracefully to a plain DOM swap on Firefox, Safari < 18, and older Chrome.
Browser support
Navigate with a transition
navigateWithTransition is the drop-in replacement for dispatchJorvelNavigate + a fade. Both end up calling history.pushState; the only difference is the wrapped DOM mutation.
import { navigateWithTransition } from '@jorvel/runtime';
<button onClick={() => navigateWithTransition({ to: '/dashboard/settings' })}>
Settings
</button>Wrap any state change
Theme switches, layout toggles, modal open/close — anything that mutates the DOM in a single tick benefits from a transition.
import { withViewTransition } from '@jorvel/runtime';
await withViewTransition(() => {
setTheme('dark');
});Reduced motion
Transitions are skipped when the user sets prefers-reduced-motion: reduce. Override via { respectReducedMotion: false } if your UI needs the animation for layout — the transition becomes informational rather than decorative.
navigateWithTransition({
to: '/products/42',
respectReducedMotion: false, // for an order morph, the motion *is* the affordance
});Global CSS
The default transition is whatever you put in ::view-transition-old(root) and ::view-transition-new(root). A simple crossfade:
::view-transition-old(root) {
animation: fade-out 0.2s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-in;
}
@keyframes fade-out { to { opacity: 0 } }
@keyframes fade-in { from { opacity: 0 } }Named transitions
Assign a view-transition-name on an element to animate it between routes — e.g. a hero image that persists across navigations or a card that morphs into a detail view. The browser handles the FLIP-style position interpolation automatically.
// /products list
<img
src={p.thumb}
style={{ viewTransitionName: 'product-' + p.id }}
alt={p.name}
/>
// /products/:id detail
<img
src={p.hero}
style={{ viewTransitionName: 'product-' + p.id }}
alt={p.name}
/>::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(.2,.8,.2,1);
}Names must be unique per snapshot
view-transition-name in the same snapshot crash the transition silently (in spec — the browser falls back to no animation). Make sure the name only appears once on the from-page and once on the to-page.Feature detection
import { supportsViewTransitions, prefersReducedMotion } from '@jorvel/runtime';
if (supportsViewTransitions() && !prefersReducedMotion()) {
// safe to schedule a transition
}API reference
| Export | Signature | Notes |
|---|---|---|
navigateWithTransition | (opts: { to, mode?, state?, respectReducedMotion? }) => Promise<void> | Promise resolves after the transition finishes (or immediately if unsupported). |
withViewTransition | (mutate: () => void | Promise<void>, opts?) => Promise<void> | Generic wrapper for any DOM-mutating callback. |
supportsViewTransitions | () => boolean | Cheap synchronous feature check. |
prefersReducedMotion | () => boolean | Reads the (prefers-reduced-motion: reduce) media query. |
SSR considerations
The View Transitions API is browser-only — none of these helpers run on the server. On unsupported runtimes the callback executes synchronously, so SSR rendering is not affected.
Common patterns
Crossfade between routes (default)
Set the global crossfade CSS once; every navigation animates.
Slide between top-level routes
::view-transition-old(root) {
animation: slide-out 250ms ease-in-out;
}
::view-transition-new(root) {
animation: slide-in 250ms ease-in-out;
}
@keyframes slide-out { to { transform: translateX(-20%); opacity: 0 } }
@keyframes slide-in { from { transform: translateX(20%); opacity: 0 } }Theme swap with view transition
import { withViewTransition } from '@jorvel/runtime';
const toggleTheme = () => withViewTransition(() => {
document.documentElement.dataset.theme =
document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
});Don't animate things users didn't ask for
