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.
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+opacityon 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.
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.
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.
Related
- View Transition Types & Nested Groups — the parent guide covering types, classes and nesting
- Implementing Cross-Document View Transitions in SPAs — where the router hands control to the transition
- Handling View Transitions with Reduced Motion — the gate every directional transition needs