JORVEL

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

Chrome 111+, Edge 111+, Safari 18+, Firefox behind flag. Unsupported browsers run the update synchronously — your UI never gets "stuck" on a missing API.

navigateWithTransition is the drop-in replacement for dispatchJorvelNavigate + a fade. Both end up calling history.pushState; the only difference is the wrapped DOM mutation.

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

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

ts
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:

css
::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.

tsx
// /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}
/>
css
::view-transition-old(*),
::view-transition-new(*) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(.2,.8,.2,1);
}

Names must be unique per snapshot

Two elements with the same 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

ts
import { supportsViewTransitions, prefersReducedMotion } from '@jorvel/runtime';

if (supportsViewTransitions() && !prefersReducedMotion()) {
  // safe to schedule a transition
}

API reference

ExportSignatureNotes
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() => booleanCheap synchronous feature check.
prefersReducedMotion() => booleanReads 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

css
::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

ts
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

Reduced-motion users perceive arbitrary animation as motion-sickness or just noise. JORVEL respects the OS setting by default; only override it for transitions that carry information (e.g. order morph between list ↔ detail).