Direction-Aware View Transitions in React with addTransitionType
Learn how addTransitionType tags a React Transition with its cause so ViewTransition, CSS and event handlers can pick a slide direction without extra state.
This article is published in English.
Sources: addTransitionType in React 19.3: Direction-Aware Animations Without Direction State
Picture a multi-step checkout with Next and Back buttons. Both of them update the same step value, yet the animations should differ: moving forward should bring the new panel in from the right, moving backward should bring it in from the left. Because the state change looks identical either way, the component has no built-in way to know which animation to play, and the classic workaround is a separate direction value that you hope is still accurate when the animation finally runs.
React 19.3 adds addTransitionType to solve this cleanly. You call it inside startTransition to record the reason for an update, and <ViewTransition> uses that reason to choose an animation. After reading this, you will know the three places a Transition Type can be consumed, how types combine, which pitfalls to watch for, and how to get the same effect on older React versions. It builds on the <ViewTransition> component itself; if that component is new to you, start with the React reference for it first.
The whole API in two imports
The function lives in the main react package next to startTransition.
import { startTransition, addTransitionType } from 'react';
Each navigation handler opens a Transition, labels it with a string, and then performs the ordinary state update. The label can be any string you like; here the two handlers use next and back.
function goNext() {
startTransition(() => {
addTransitionType('next'); // any string
setStep(s => s + 1);
});
}function goBack() {
startTransition(() => {
addTransitionType('back');
setStep(s => s - 1);
});
}
The contract is small. addTransitionType(type) accepts a single string and has no return value. It must run inside the callback passed to startTransition. If you call it anywhere else, the development build of React logs the warning "addTransitionType can only be called inside a startTransition() callback" and discards the type. Multiple calls are allowed, and React gathers every type onto the same Transition.
The interesting part is not the function but its readers. There are three of them.
Consumer 1: object values on ViewTransition props
Each trigger prop of <ViewTransition> (enter, exit, update, share and default) can take an object rather than a plain class name. The object maps Transition Types to a class name, to "auto", or to "none". A default key acts as the fallback when none of the listed types is present.
<ViewTransition
enter={{ next: 'from-right', back: 'from-left', default: 'auto' }}
exit={{ next: 'to-left', back: 'to-right', default: 'auto' }}
>
<Step />
</ViewTransition>
A complete wizard driven by types
The following wizard puts that into practice. Notice the key={step} on the boundary: every step change unmounts the old panel and mounts a new one, so React treats it as an exit plus an enter, and the active type decides the direction of both halves.
import { ViewTransition, useState, startTransition, addTransitionType } from 'react';
A single go helper receives the step delta and the type name, so both buttons share one code path.
const STEPS = ['Cart', 'Address', 'Payment', 'Review'];export default function App() {
const [step, setStep] = useState(0); function go(delta, type) {
startTransition(() => {
addTransitionType(type);
setStep(s => s + delta);
});
} return (
<>
<div className="bar">
<button disabled={step === 0} onClick={() => go(-1, 'back')}>← Back</button>
<span>Step {step + 1} of {STEPS.length}</span>
<button disabled={step === STEPS.length - 1} onClick={() => go(1, 'next')}>Next →</button>
</div> <div className="stage">
<ViewTransition
key={step}
enter={{ next: 'from-right', back: 'from-left' }}
exit={{ next: 'to-left', back: 'to-right' }}
>
<section className="panel">{STEPS[step]}</section>
</ViewTransition>
</div>
</>
);
}
The layout styles position the panel absolutely inside a clipped stage, which keeps the outgoing and incoming snapshots from pushing each other around.
.bar { display: flex; align-items: center; gap: 12px; }
.stage { position: relative; height: 90px; margin-top: 12px; overflow: hidden; border-radius: 12px; }
.panel {
position: absolute; inset: 0;
display: grid; place-items: center;
font: 600 22px system-ui;
background: light-dark(#e0e7ff, #1e1b4b);
}
The animation rules target the view transition pseudo-elements by class. Old snapshots get the to-left or to-right exit keyframes, new snapshots get from-right or from-left, and the four keyframes simply translate by a full width while fading.
::view-transition-old(.to-left) { animation: 260ms ease-in both to-left; }
::view-transition-old(.to-right) { animation: 260ms ease-in both to-right; }
::view-transition-new(.from-right) { animation: 300ms ease-out both from-right; }
::view-transition-new(.from-left) { animation: 300ms ease-out both from-left; }@keyframes to-left { to { transform: translateX(-100%); opacity: 0; } }
@keyframes to-right { to { transform: translateX(100%); opacity: 0; } }
@keyframes from-right { from { transform: translateX(100%); opacity: 0; } }
@keyframes from-left { from { transform: translateX(-100%); opacity: 0; } }
Look at what the component does not contain: there is no stored direction anywhere. The direction belongs to the update, is attached at the instant the update is scheduled, and moves through the React scheduler with it. When a user presses Back twice quickly while the first slide is still playing, React batches the queued work and the second animation reads the types gathered for that second Transition. No directionRef exists that could fall out of sync.
How React resolves matching types
React applies these rules, in this order:
- When more than one type matches, the corresponding class names are concatenated and all of them are applied.
- When nothing matches, React falls back to the
defaultentry; without one, that trigger does not animate. - When any matched entry is
"none", it takes precedence and the boundary skips animation entirely.
Consumer 2: native view transition types in CSS
React also hands every Transition Type to the browser as a native view transition type. As a result, you can scope animations from a stylesheet using the :active-view-transition-type() pseudo-class and leave the component props alone.
The first step is to give the element a view-transition-class. The snapshot inherits that class, and React does not overwrite it.
/* give the element a class the snapshot inherits — React leaves it alone */
.panel { view-transition-class: panel; }
Then write rules that only apply while a transition with a given type is running. The nesting syntax keeps both pseudo-elements for one direction together.
/* applies only while a transition tagged 'next' is running */
:root:active-view-transition-type(next) {
&::view-transition-old(.panel) { animation: 220ms ease-in both to-left; }
&::view-transition-new(.panel) { animation: 280ms ease-out both from-right; }
}
:root:active-view-transition-type(back) {
&::view-transition-old(.panel) { animation: 220ms ease-in both to-right; }
&::view-transition-new(.panel) { animation: 280ms ease-out both from-left; }
}
The same wizard, styled only from CSS
This version of the wizard passes no enter or exit props at all. The boundary is bare, and the stylesheet makes every decision from the active type.
import { ViewTransition, useState, startTransition, addTransitionType } from 'react';
The component code is otherwise identical to the first demo.
const STEPS = ['Cart', 'Address', 'Payment', 'Review'];export default function App() {
const [step, setStep] = useState(0); function go(delta, type) {
startTransition(() => {
addTransitionType(type);
setStep(s => s + delta);
});
} return (
<>
<div className="bar">
<button disabled={step === 0} onClick={() => go(-1, 'back')}>← Back</button>
<span>Step {step + 1} of {STEPS.length}</span>
<button disabled={step === STEPS.length - 1} onClick={() => go(1, 'next')}>Next →</button>
</div>
<div className="stage">
<ViewTransition key={step}>
<section className="panel">{STEPS[step]}</section>
</ViewTransition>
</div>
</>
);
}
The panel styles now include view-transition-class: panel, which is what the selectors below hook into.
.bar { display: flex; align-items: center; gap: 12px; }
.stage { position: relative; height: 90px; margin-top: 12px; overflow: hidden; border-radius: 12px; }
.panel {
position: absolute; inset: 0;
display: grid; place-items: center;
font: 600 22px system-ui;
background: light-dark(#fce7f3, #4a1d3a);
view-transition-class: panel;
}
The type-scoped rules sit on :root, and the keyframes are unchanged from before.
/* The type React forwarded to the browser scopes the whole rule */
:root:active-view-transition-type(next) {
&::view-transition-old(.panel) { animation: 260ms ease-in both to-left; }
&::view-transition-new(.panel) { animation: 300ms ease-out both from-right; }
}
:root:active-view-transition-type(back) {
&::view-transition-old(.panel) { animation: 260ms ease-in both to-right; }
&::view-transition-new(.panel) { animation: 300ms ease-out both from-left; }
}@keyframes to-left { to { transform: translateX(-100%); opacity: 0; } }
@keyframes to-right { to { transform: translateX(100%); opacity: 0; } }
@keyframes from-right { from { transform: translateX(100%); opacity: 0; } }
@keyframes from-left { from { transform: translateX(-100%); opacity: 0; } }
Choose this style when an entire page should react to one cause, for instance a router that tags navigations as navigation-forward or navigation-back. Prefer the object props when one particular boundary needs its own mapping. Keep the view-transition-class line: if you target (*) instead of (.panel), the rule also matches the root snapshot, and the whole page slides together with the panel.
Consumer 3: event callbacks
The onEnter, onExit, onUpdate and onShare callbacks get the collected types as their second parameter. That lets imperative animation code branch on the cause as well. In this snippet, the entering element starts off-screen on the left for back and on the right otherwise, is animated with the Web Animations API, and returns a cleanup function that cancels the animation if React interrupts it.
<ViewTransition
onEnter={(instance, types) => {
const from = types.includes('back') ? '-100%' : '100%';
const anim = instance.new.animate(
[{ transform: `translateX(${from})` }, { transform: 'none' }],
{ duration: 300, easing: 'ease-out' }
);
return () => anim.cancel();
}}
>
Returning the cleanup matters: without it, a quickly interrupted transition could leave a running animation attached to a snapshot that no longer exists.
Layering several types on one Transition
Because React keeps every type added inside the Transition, you can stack an independent concern on top of direction. In this carousel, a "quick" checkbox adds a second type named quick. Its class only shortens the duration, and both classes end up on the snapshot together.
import { ViewTransition, useState, startTransition, addTransitionType } from 'react';
The go helper always adds the direction and conditionally adds quick. Each prop object maps quick to a class of the same name, so a matched direction class and the quick class are joined.
const SLIDES = ['🍎', '🍋', '🥝', '🍇'];export default function App() {
const [i, setI] = useState(0);
const [quick, setQuick] = useState(false); function go(delta, type) {
startTransition(() => {
addTransitionType(type);
if (quick) addTransitionType('quick');
setI(n => (n + delta + SLIDES.length) % SLIDES.length);
});
} return (
<>
<div className="bar">
<button onClick={() => go(-1, 'back')}>←</button>
<button onClick={() => go(1, 'next')}>→</button>
<label><input type="checkbox" checked={quick} onChange={e => setQuick(e.target.checked)} /> quick</label>
</div>
<div className="stage">
<ViewTransition
key={i}
enter={{ next: 'from-right', back: 'from-left', quick: 'quick' }}
exit={{ next: 'to-left', back: 'to-right', quick: 'quick' }}
>
<div className="slide">{SLIDES[i]}</div>
</ViewTransition>
</div>
</>
);
}
The layout is the same idea as before, with a taller stage for the large emoji.
.bar { display: flex; align-items: center; gap: 12px; }
.stage { position: relative; height: 110px; margin-top: 12px; overflow: hidden; border-radius: 12px; }
.slide { position: absolute; inset: 0; display: grid; place-items: center; font-size: 56px; background: light-dark(#fef9c3, #3f3a12); }
The direction rules set a deliberately slow 700ms animation, and the quick rule overrides only animation-duration, leaving the keyframes and easing intact.
::view-transition-old(.to-left) { animation: 700ms ease-in both to-left; }
::view-transition-old(.to-right) { animation: 700ms ease-in both to-right; }
::view-transition-new(.from-right) { animation: 700ms ease-out both from-right; }
::view-transition-new(.from-left) { animation: 700ms ease-out both from-left; }/* second class on the same snapshot: only the duration changes */
::view-transition-old(.quick), ::view-transition-new(.quick) { animation-duration: 120ms; }@keyframes to-left { to { transform: translateX(-100%); opacity: 0; } }
@keyframes to-right { to { transform: translateX(100%); opacity: 0; } }
@keyframes from-right { from { transform: translateX(100%); opacity: 0; } }
@keyframes from-left { from { transform: translateX(-100%); opacity: 0; } }
This composition is the real payoff of the type system: orthogonal concerns such as direction, speed or emphasis can be expressed as separate labels instead of a growing matrix of combined class names.
The state-based approach it replaces
Before this API, the direction usually lived in component state, which is the wrong home for it. The typical setup adds a second piece of state:
const [step, setStep] = useState(0);
const [direction, setDirection] = useState('next');
Each handler writes the direction and then the step, and the render output bakes the direction into a class name:
function goNext() {
setDirection('next');
setStep(s => s + 1);
}// then, in render:
<div className={`panel enter-${direction}`} key={step}>…</div>
That pattern has three weaknesses:
- The extra state triggers a re-render for information that is not really UI.
- Two fast clicks in opposite directions can leave
directiondescribing a different update than the one being animated. - When
stepchanges for some other reason, such as a deep link or a restart button, the leftover direction gets applied to an update it has nothing to do with.
Animation libraries tend to hide the same problem behind a prop. Framer Motion's custom={direction} passed to variants is still a stored value, just better named.
Why moving the cause onto the update matters
With addTransitionType, the cause is part of the update rather than part of state. The change is small, but the consequences are significant: the cause cannot go stale, cannot bleed into unrelated updates, and costs no render. It also creates a common channel for libraries. A router can label forward and backward navigations once, and every <ViewTransition> on any page can react to those labels. The React documentation says it expects routers and other libraries to converge on shared type names.
The React team has also said that types are not limited to animation and that more uses for the cause of a transition are planned. The underlying mechanism, attaching metadata to a Transition and reading it at commit time, is general enough to support that.
Pitfalls to know before shipping
- The type only counts inside
startTransition. Outside of it, React drops the type with a warning that appears only in development, and since the update is urgent, nothing animates anyway. - Types are cleared after each commit. If the Transition first displays a
<Suspense>fallback, that fallback commit carries your types, while the later reveal of the actual content does not. For direction-aware animations across a loading boundary, add the type again when the content is ready, or design the fallback-to-content swap so it does not depend on direction. "none"always wins. If any matched entry resolves to"none", that trigger is disabled even when another matched type supplied a class.- An object without a
defaultkey only animates for the types it lists, so untagged Transitions passing through that boundary will not animate. That is frequently intentional, but easy to overlook. - Browser support differs between the two routes. Since React sets the class on the snapshot on its own, the object-prop approach runs in any browser with View Transition Classes: Chrome 125, Safari 18.2 and Firefox 144. Styling through
:active-view-transition-type()has a slightly higher bar of Chrome 125, Safari 18.2 or Firefox 147. Check MDN's compatibility data for the current picture. - Type names are global strings that reach the browser unchanged. Prefix them, for example
wizard-next, when two features on one page might both usenext.
A fallback for React 19.2 and earlier
Older React versions cannot use addTransitionType, but the browser API can still carry the type. You give up React's coordination of the transition, not the direction information. Call document.startViewTransition yourself, pass types, and reuse the CSS from the second consumer without changes. You need flushSync so the state update is applied synchronously inside the browser's update callback:
import { flushSync } from 'react-dom';
The helper also degrades gracefully: when the browser lacks startViewTransition, it simply updates state without animation.
function go(delta, type) {
if (!document.startViewTransition) {
setStep(s => s + delta);
return;
}
document.startViewTransition({
update: () => flushSync(() => setStep(s => s + delta)),
types: [type],
});
}
After upgrading to 19.3, the body of go shrinks back to the short startTransition version from the first section, and the stylesheet does not change at all.
Key takeaways
- Treat animation direction as metadata about an update, not as component state;
addTransitionTypeis the tool for that. - Consume types through object props for per-boundary mappings, through
:active-view-transition-type()for page-wide rules, or through event callbacks for imperative animations. - Stack independent types, such as direction plus speed, instead of inventing combined class names.
- Remember the sharp edges: Suspense reveals lose their types,
"none"overrides everything, and type names share a global namespace. - For the full API details, see the addTransitionType reference and the type-based customization section of the ViewTransition docs.