Choosing View Transition Types for Route Direction

Part of Transition Types & Nested Groups in Modern View Transitions & Scroll APIs.

The problem

Directional motion is the single cheapest way to make navigation legible: content that slides in from the right reads as “forward”, content that slides in from the left reads as “back”. Without it, a crossfade makes every navigation feel like the same undifferentiated swap, and users lose track of where they are in a flow.

The obstacle is that the browser does not tell you the direction. document.startViewTransition() knows a DOM change is coming; it has no opinion about whether that change is a step forward or a step back, and neither does the URL.

Root cause: direction is a property of history, not of the URL

Teams usually reach for one of three unreliable signals first.

Comparing path depth breaks as soon as a flow moves sideways: a detail page navigating to a sibling detail page has the same depth, and a breadcrumb click can jump two levels in one navigation. Reading event.navigationType from the Navigation API gets closer but only distinguishes push from traverse, not forward-traverse from back-traverse. And listening for popstate tells you a traversal happened without telling you which way.

The reliable signal is an index you write yourself. Every time you push a history entry, store an incrementing number in its state. On any navigation, compare the incoming entry’s index with the one you were on: larger is forward, smaller is back, equal or missing is neutral. That single integer survives reloads, restored sessions and multi-level jumps, because it is attached to the history entry rather than derived from it.

Deriving the direction reliablyOne integer in history state answers a question the URL cannot.Deriving the direction reliably1On every pushState, write an index that is one greater than the currententry's.Each entry carries its position in the stack2Keep the current index in a module variable, updated on every navigation.One comparison, no DOM reads3On navigation, compare the incoming index with the stored one.Greater is forward, smaller is back4Treat a missing index as neutral rather than guessing.First load and restored sessions crossfade5Pass the result as the transition type and update the stored index.
One integer in history state answers a question the URL cannot.

Production code pattern

// Intent: one place derives the direction, one call communicates it, and the
// stylesheet decides what each direction looks like.
let currentIndex = history.state?.vtIndex ?? 0;

function pushRoute(url) {
  history.pushState({ vtIndex: currentIndex + 1 }, '', url);
}

function directionFor(nextIndex) {
  if (typeof nextIndex !== 'number') return 'neutral';   // first load, restore
  if (nextIndex > currentIndex) return 'forward';
  if (nextIndex < currentIndex) return 'back';
  return 'neutral';                                      // replace, same entry
}

async function renderRoute(url, nextIndex) {
  const view = await loadRoute(url);

  if (!document.startViewTransition) {   // unsupported: swap and move on
    applyView(view);
    currentIndex = nextIndex ?? currentIndex;
    return;
  }

  const transition = document.startViewTransition({
    update: () => applyView(view),
    types: [directionFor(nextIndex)],
  });

  currentIndex = nextIndex ?? currentIndex;
  await transition.finished.catch(() => {});  // a skipped transition is fine
}

addEventListener('popstate', (event) => {
  renderRoute(location.pathname, event.state?.vtIndex);
});
/* Intent: the two directions are exact mirrors, and neutral is the fallback
   for anything the router could not classify. */
html:active-view-transition-type(forward) {
  &::view-transition-old(root) { animation: 200ms ease-in both slide-out-start; }
  &::view-transition-new(root) { animation: 260ms ease-out both slide-in-end; }
}

html:active-view-transition-type(back) {
  &::view-transition-old(root) { animation: 200ms ease-in both slide-out-end; }
  &::view-transition-new(root) { animation: 260ms ease-out both slide-in-start; }
}

@keyframes slide-out-start { to   { translate: -24px 0; opacity: 0; } }
@keyframes slide-in-end    { from { translate: 24px 0;  opacity: 0; } }
@keyframes slide-out-end   { to   { translate: 24px 0;  opacity: 0; } }
@keyframes slide-in-start  { from { translate: -24px 0; opacity: 0; } }

/* Accessibility gate: every direction collapses to the same short crossfade. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: 120ms linear both fade;
  }
  @keyframes fade { from { opacity: 0; } }
}

Rendering Impact: translate + opacity on the snapshot pseudo-elements — composite only. The type selector is matched once during style; the direction logic is three integer comparisons on the main thread before the transition starts.

Signals for deriving direction, and how each one failsOnly the history index answers the question directly.Signals for deriving direction, and how each one failsHandles sidewaysmovesHandles multi-leveljumpsSurvives reloadURL path depthNoNoYespopstate aloneYesYesNo directionNavigation APInavigationTypeYesPartiallyYesIndex in historystateYesYesYes
Only the history index answers the question directly.

Verification checklist

Constraints and trade-offs

  • History state has a size limit and is serialised on every push; keep the index and nothing else in it.
  • A hard navigation to a new document resets the module variable, which is why the missing-index case must be handled as neutral rather than as forward.
  • Two rapid navigations can start a second transition before the first finishes; the browser skips the first, so the direction of a fast double-tap may not be visible.
  • Directional slides move the largest possible area, so they are the first thing to remove under reduced motion rather than the last.
  • Typed rules multiply: three directions and two content kinds is six rule sets, which is an argument for keeping the vocabulary small.

Frequently asked questions

Why not derive the direction from the URL depth?

Because depth and direction are different things. Navigating from a detail page to a sibling detail page is neither deeper nor shallower, and a breadcrumb jump can skip several levels at once. A monotonic index written into history state answers the actual question: is this entry newer or older than the one we were on?

Can I use more than one type at a time?

Yes. types is an array, and :active-view-transition-type() matches if any listed type is active. A transition can be both ‘back’ and ‘filter’, letting one rule set handle direction and another handle the kind of content change.

What happens to types if the transition is skipped?

They are cleared along with everything else. Because the flag exists only while the transition is live, a skipped or aborted transition leaves no state behind and the next navigation starts clean.


Testing the direction logic without a browser

The direction derivation is ordinary logic over integers, which means it can be unit-tested in isolation — and it should be, because the failures are subtle and only visible as motion.

Extract directionFor into a module that takes the stored index and the incoming index and returns a string. Then the whole matrix of cases is a table test: forward from 0 to 1, back from 3 to 1, neutral for a replace at the same index, neutral for undefined on a cold load, and neutral for a restored session where the stored index is higher than anything the current page knows about.

Cases the unit test should coverEach row is a real navigation shape that has produced a bug in someone's router.Cases the unit test should coverStoredIncomingExpectedNormal forwardstep23forwardBrowser backbutton32backBreadcrumb jump uptwo levels42backReplace in place22neutralCold load0undefinedneutralRestored session07forward
Each row is a real navigation shape that has produced a bug in someone's router.

The restored-session row is the interesting one. After a reload deep in a flow, the module variable resets to zero while history still holds a high index, so the first navigation is classified as forward even if the user pressed back. Treating a jump of more than one as neutral is a reasonable refinement, and it is much easier to reason about in a unit test than in a browser.