Transition
What is Transition?
<Transition> is a built-in component for applying animations when showing or hiding elements and components. It works with CSS transitions/animations to achieve smooth UI transitions.

When you toggle element visibility with v-if, elements appear and disappear instantly. With Transition, you can easily add animations like fade-in/out or slides!
Main use cases:
- Combining with v-if / v-show: Animations for conditional rendering
- Dynamic components: Switching animations with
<component :is> - Route transitions: Transition effects between pages
Basic Usage
<template>
<button @click="show = !show">Toggle</button>
<Transition name="fade">
<p v-if="show">Hello</p>
</Transition>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>Implementation Overview
Props Definition
export interface TransitionProps {
name?: string;
type?: "transition" | "animation";
css?: boolean;
duration?: number | { enter: number; leave: number };
enterFromClass?: string;
enterActiveClass?: string;
enterToClass?: string;
appearFromClass?: string;
appearActiveClass?: string;
appearToClass?: string;
leaveFromClass?: string;
leaveActiveClass?: string;
leaveToClass?: string;
mode?: "in-out" | "out-in" | "default";
appear?: boolean;
// Lifecycle hooks
onBeforeEnter?: (el: Element) => void;
onEnter?: (el: Element, done: () => void) => void;
onAfterEnter?: (el: Element) => void;
onEnterCancelled?: (el: Element) => void;
onBeforeLeave?: (el: Element) => void;
onLeave?: (el: Element, done: () => void) => void;
onAfterLeave?: (el: Element) => void;
onLeaveCancelled?: (el: Element) => void;
// Appear hooks
onBeforeAppear?: (el: Element) => void;
onAppear?: (el: Element, done: () => void) => void;
onAfterAppear?: (el: Element) => void;
onAppearCancelled?: (el: Element) => void;
}TransitionHooks Interface
export interface TransitionHooks<HostElement = Element> {
mode: string;
beforeEnter(el: HostElement): void;
enter(el: HostElement): void;
leave(el: HostElement, remove: () => void): void;
clone(vnode: VNode): TransitionHooks<HostElement>;
}The renderer coordinates with Transition through this interface.
CSS Class Lifecycle
Transition automatically adds and removes the following CSS classes:
Enter (Showing Element)
- v-enter-from: Start state. Added before the element is inserted, removed after 1 frame
- v-enter-active: Active state. Applied throughout the entire transition
- v-enter-to: End state. Added 1 frame after start, removed when transition ends
Leave (Hiding Element)
- v-leave-from: Start state. Added when leave transition starts, removed after 1 frame
- v-leave-active: Active state. Applied throughout the entire transition
- v-leave-to: End state. Added 1 frame after start, removed when transition ends
Enter:
┌──────────────────────────────────────────┐
│ v-enter-from → (1 frame) → v-enter-to │
│ ├─────── v-enter-active ──────────────┤ │
└──────────────────────────────────────────┘
Leave:
┌──────────────────────────────────────────┐
│ v-leave-from → (1 frame) → v-leave-to │
│ ├─────── v-leave-active ──────────────┤ │
└──────────────────────────────────────────┘Core Logic Implementation
resolveTransitionProps
Parses props and generates TransitionHooks.
export function resolveTransitionProps(
rawProps: TransitionProps
): TransitionProps & TransitionHooks {
const {
name = "v",
type,
css = true,
duration,
enterFromClass = `${name}-enter-from`,
enterActiveClass = `${name}-enter-active`,
enterToClass = `${name}-enter-to`,
// ... other classes
mode = "default",
} = rawProps;
const durations = normalizeDuration(duration);
const enterDuration = durations && durations[0];
const leaveDuration = durations && durations[1];
// Generate hook functions
return {
...rawProps,
mode,
beforeEnter(el) {
callHook(onBeforeEnter, [el]);
addTransitionClass(el, enterFromClass);
addTransitionClass(el, enterActiveClass);
},
enter: makeEnterHook(false),
leave(el, done) {
// leave logic
},
clone(vnode) {
return resolveTransitionProps(rawProps);
},
};
}CSS Class Management
export interface ElementWithTransition extends HTMLElement {
_vtc?: Set<string>;
}
export function addTransitionClass(
el: Element & ElementWithTransition,
cls: string
): void {
cls.split(/\s+/).forEach((c) => c && el.classList.add(c));
(el._vtc || (el._vtc = new Set())).add(cls);
}
export function removeTransitionClass(
el: Element & ElementWithTransition,
cls: string
): void {
cls.split(/\s+/).forEach((c) => c && el.classList.remove(c));
const { _vtc } = el;
if (_vtc) {
_vtc.delete(cls);
if (!_vtc.size) {
el._vtc = undefined;
}
}
}The _vtc (Vue Transition Classes) property tracks the currently applied transition classes.
nextFrame
To make CSS transitions work correctly, we wait 2 frames before changing classes.
function nextFrame(cb: () => void): void {
requestAnimationFrame(() => {
requestAnimationFrame(cb);
});
}The first frame allows the browser to recognize the initial state, and the second frame applies the change, ensuring the transition fires reliably.

"Why call requestAnimationFrame twice?" you might wonder. The first call tells the browser "this is the initial state," and the second call tells it "this is the end state," allowing the browser to recognize the transition!
Enter Hook
const makeEnterHook = (isAppear: boolean) => {
return (el: Element, done: () => void) => {
const hook = isAppear ? onAppear : onEnter;
const resolve = () => finishEnter(el, isAppear, done);
callHook(hook, [el, resolve]);
nextFrame(() => {
removeTransitionClass(el, isAppear ? appearFromClass : enterFromClass);
addTransitionClass(el, isAppear ? appearToClass : enterToClass);
if (!hasExplicitCallback(hook)) {
whenTransitionEnds(el, type, enterDuration, resolve);
}
});
};
};- Call user-defined hooks
- After 2 frames, remove the from class and add the to class
- Detect transition end and complete the process
Leave Hook
leave(el, done) {
const resolve = () => finishLeave(el, done);
addTransitionClass(el, leaveFromClass);
// Force reflow
forceReflow();
addTransitionClass(el, leaveActiveClass);
nextFrame(() => {
removeTransitionClass(el, leaveFromClass);
addTransitionClass(el, leaveToClass);
if (!hasExplicitCallback(onLeave)) {
whenTransitionEnds(el, type, leaveDuration, resolve);
}
});
callHook(onLeave, [el, resolve]);
}Detecting Transition End
getTransitionInfo
Gets transition/animation information from CSS.
export function getTransitionInfo(
el: Element,
expectedType?: TransitionProps["type"]
): CSSTransitionInfo {
const styles = window.getComputedStyle(el);
const transitionDelays = getStyleProperties("transitionDelay").split(", ");
const transitionDurations = getStyleProperties("transitionDuration").split(", ");
const transitionTimeout = getTimeout(transitionDelays, transitionDurations);
const animationDelays = getStyleProperties("animationDelay").split(", ");
const animationDurations = getStyleProperties("animationDuration").split(", ");
const animationTimeout = getTimeout(animationDelays, animationDurations);
// Determine whether to use transition or animation
let type: CSSTransitionInfo["type"] = null;
let timeout = 0;
let propCount = 0;
if (expectedType === TRANSITION) {
if (transitionTimeout > 0) {
type = TRANSITION;
timeout = transitionTimeout;
propCount = transitionDurations.length;
}
} else if (expectedType === ANIMATION) {
// For animation
} else {
// Auto-detect
timeout = Math.max(transitionTimeout, animationTimeout);
type = timeout > 0
? (transitionTimeout > animationTimeout ? TRANSITION : ANIMATION)
: null;
}
return { type, timeout, propCount, hasTransform };
}whenTransitionEnds
Executes a callback when the transition ends.
export function whenTransitionEnds(
el: Element & { _endId?: number },
expectedType: TransitionProps["type"] | undefined,
explicitTimeout: number | null,
resolve: () => void
): void {
const id = (el._endId = ++endId);
const resolveIfNotStale = () => {
if (id === el._endId) {
resolve();
}
};
// If explicit timeout is provided, use it
if (explicitTimeout) {
return setTimeout(resolveIfNotStale, explicitTimeout);
}
const { type, timeout, propCount } = getTransitionInfo(el, expectedType);
if (!type) {
return resolve();
}
const endEvent = type + "end"; // "transitionend" or "animationend"
let ended = 0;
const onEnd = (e: Event) => {
if (e.target === el && ++ended >= propCount) {
end();
}
};
// Timeout fallback
setTimeout(() => {
if (ended < propCount) {
end();
}
}, timeout + 1);
el.addEventListener(endEvent, onEnd);
}Key points:
- Monitors
transitionend/animationendevents - Waits for as many events as there are properties
- Timeout fallback (insurance in case the event doesn't fire)
_endIdcancels old transitions
forceReflow
Forces a reflow to ensure CSS transitions fire reliably.
export function forceReflow(): number {
return document.body.offsetHeight;
}Reading offsetHeight forces the browser to recalculate styles.

Even when CSS classes are added consecutively, the browser may batch style recalculations for optimization. Reading offsetHeight forces it to "calculate now!"
Transition Component Body
const Transition = (
props: TransitionProps,
{ slots }: { slots: any }
): VNode | null => {
const innerProps = resolveTransitionProps(props);
const children = slots.default && slots.default();
if (!children || children.length === 0) {
return null;
}
const child = children[0];
if (child) {
// Set transition hooks on the VNode
child.transition = innerProps;
}
return child;
};Transition itself doesn't render any DOM elements; it just attaches a transition property to the child VNode. The renderer sees this property and calls the hooks.
Usage Examples
Basic Fade
<template>
<Transition name="fade">
<p v-if="show">Hello</p>
</Transition>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>Slide Animation
<template>
<Transition name="slide">
<p v-if="show">Hello</p>
</Transition>
</template>
<style>
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>JavaScript Hooks
<template>
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
:css="false"
>
<p v-if="show">Hello</p>
</Transition>
</template>
<script setup>
function onBeforeEnter(el) {
el.style.opacity = 0;
}
function onEnter(el, done) {
// Use an animation library like GSAP
gsap.to(el, {
opacity: 1,
duration: 0.5,
onComplete: done,
});
}
function onLeave(el, done) {
gsap.to(el, {
opacity: 0,
duration: 0.5,
onComplete: done,
});
}
</script>Explicit Duration
<template>
<!-- enter: 300ms, leave: 500ms -->
<Transition name="fade" :duration="{ enter: 300, leave: 500 }">
<p v-if="show">Hello</p>
</Transition>
</template>Integration with VNode
VNode.transition Property
VNode has a transition property that stores TransitionHooks.
// packages/runtime-core/src/vnode.ts
export interface VNode<HostNode = any> {
// ... other properties
// transition
transition: any | null;
}Setting in Transition Component
The Transition component sets the transition property on the child VNode.
const Transition = (
props: TransitionProps,
{ slots }: { slots: any }
): VNode | null => {
const innerProps = resolveTransitionProps(props);
const children = slots.default && slots.default();
if (!children || children.length === 0) {
return null;
}
const child = children[0];
if (child) {
// Set transition hooks on the VNode
child.transition = innerProps;
}
return child;
};Processing in the Renderer
The renderer detects the VNode's transition property and calls hooks at appropriate times:
- When inserting element:
beforeEnter→ DOM insertion →enter - When removing element:
leave→ DOM removal
// Conceptual processing flow
const mountElement = (vnode, container, anchor) => {
const el = createElement(vnode.type);
// Call beforeEnter if there's a transition
if (vnode.transition) {
vnode.transition.beforeEnter(el);
}
// Insert into DOM
insert(el, container, anchor);
// Call enter if there's a transition
if (vnode.transition) {
vnode.transition.enter(el);
}
};
const unmountElement = (vnode) => {
const el = vnode.el;
// Call leave if there's a transition
if (vnode.transition) {
vnode.transition.leave(el, () => {
// Remove from DOM after leave completes
remove(el);
});
} else {
remove(el);
}
};Processing Flow
Transition component render
↓
Generate TransitionHooks with resolveTransitionProps
↓
child.transition = innerProps
↓
Renderer mountElement
├── beforeEnter(el)
│ └── Add enterFromClass/enterActiveClass
├── insert(el, container)
└── enter(el, done)
└── In nextFrame
├── Remove enterFromClass
├── Add enterToClass
└── Wait for completion with whenTransitionEnds
└── done() calls finishEnter
Renderer unmountElement
└── transition.leave(el, remove)
├── Add leaveFromClass
├── forceReflow()
├── Add leaveActiveClass
└── In nextFrame
├── Remove leaveFromClass
├── Add leaveToClass
└── Wait for completion with whenTransitionEnds
└── remove() removes from DOMSummary
The Transition implementation consists of the following elements:
- CSS class management: Add/remove classes at each phase of enter/leave
- nextFrame: Wait 2 frames to guarantee transition fires
- forceReflow: Force reflow for style recalculation
- whenTransitionEnds: Monitor transitionend/animationend events
- JavaScript hooks: Support for animations without CSS
- VNode.transition: Property for the renderer to call hooks
Transition works closely with CSS transitions/animations and is implemented with a deep understanding of the browser's rendering pipeline.

Not just CSS class manipulation, but frame timing control and reflow management too - this implementation requires a deep understanding of browser internals. Surprisingly deep, isn't it!
Source code up to this point: chibivue (GitHub)
