what's truly smooth and what just flickers
Vue transitions are powerful, but full of hidden traps. A wrongly chosen CSS property triggers layout reflow and makes the animation stutter. A missing key change lets elements disappear without the transition ever firing. This article explains how Vue transitions actually work, which CSS properties run smoothly, and which patterns reliably deliver beautiful animations in practice.
Table of Contents
- 1. How Vue Transition works internally
- 2. The six CSS classes and when they are set
- 3. What's truly smooth: opacity, transform, clip-path
- 4. Transition modes: using out-in and in-out correctly
- 5. TransitionGroup: list transitions without layout jumps
- 6. Route transitions with Vue Router
- 7. JavaScript hooks for complex animations
- 8. Performance pitfalls and how to spot them
- 9. Comparison: smooth vs. janky
- 10. Summary
- 11. FAQ
1. How Vue Transition works internally
The Vue Transition component isn't purely a CSS feature, it's a runtime coordination between Vue and the browser. When an element under <Transition> appears through v-if or v-show, Vue adds and removes CSS classes at defined points in time. Vue actually waits on the browser here, specifically for the next animation frame, to make sure the browser has actually rendered the starting class before the active class is added. This wait on the browser is necessary because CSS transitions only fire when the browser observes a difference in state between two frames.
A common misunderstanding: Vue Transition doesn't start the animation. It adds CSS classes, the browser then runs the CSS transition defined in those classes. If the CSS transition property is missing or set to 0s, no visible transition happens even though Vue sets the classes correctly. If the element doesn't get time to have its starting state rendered when it's shown, because Vue and the browser act too quickly one after another, the transition starts from the wrong point. The nextTick mechanism in Vue ensures this timing bug is systematically avoided.
For v-show, behavior differs from v-if. With v-if, the element is actually removed from the DOM once the leave transition has finished. With v-show, only display: none is set instead of DOM removal. Vue Transition handles both correctly, but the leave animation with v-show sometimes collides with already defined display properties in the CSS, a common bug that causes the element to vanish instantly instead of animating.
2. The six CSS classes and when they are set
The Vue Transition component sets six CSS classes: three for the enter phase and three for the leave phase. The pattern is [name]-enter-from, [name]-enter-active, [name]-enter-to and the equivalent for leave. -from defines the starting state, -to the target state, and -active contains the actual transition declaration, meaning the duration, easing function, and the properties being animated. The most common source of errors: forgetting transition in -active. Then the element switches instantly from -from to -to with no animation at all.
Vue sets the classes in this order for enter: first -from and -active at the same time, then Vue waits one frame, removes -from, and adds -to. The browser now sees the difference between the -from values and the -to values and starts the CSS transition. After the transition completes, detected via the transitionend event, Vue removes all enter classes. This is important to understand: Vue waits for the browser's transitionend event. If that event never fires, for example because no transition property is defined or the element was removed from the DOM immediately, Vue gets stuck in an undefined state.
/* Correct Vue Transition CSS, all 6 classes defined */
/* The -active class MUST contain the transition property */
/* Fade transition, opacity only (compositor-layer, 60fps) */
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
/* transition defined here, duration, property, easing */
transition: opacity 250ms ease-in-out;
}
/* -enter-to and -leave-from inherit the component's default style */
/* (opacity: 1 by default), no need to declare them explicitly */
/* Slide-up transition, transform only (compositor, no layout) */
.slide-up-enter-from {
transform: translateY(16px);
opacity: 0;
}
.slide-up-leave-to {
transform: translateY(-16px);
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 300ms ease;
}
3. What's truly smooth: opacity, transform, clip-path
Not all CSS properties are equal for Vue transitions. The browser renders CSS transitions in two ways: properties that trigger a layout reflow (width, height, margin, padding, top, left) must be recalculated by the browser on every frame. That's CPU intensive and causes noticeable jank on weaker devices. Properties that only affect the compositor layer (opacity, transform) can be animated directly by the GPU, no layout reflow, 60fps even on mobile devices.
The golden rule for smooth Vue transitions: only animate opacity and transform. If an element needs to grow, work with transform: scale() instead of animating width. If an element should slide in from above, start with transform: translateY(-100px) instead of animating from top: -100px to top: 0. clip-path is another exception that can be animated on modern browsers without a layout reflow and is interesting for reveal effects. filter (blur, brightness) can also be animated smoothly, but is more expensive than opacity and transform on mobile devices.
4. Transition modes: using out-in and in-out correctly
When an element under <Transition> is replaced by another one, for example through a change of a :key attribute or a v-if/v-else pair, the leave and enter transitions run at the same time by default. That means the old element fades out while the new one fades in. In many layouts that causes a visual "jump" because both elements briefly take up space at the same time. The Vue Transition mode out-in solves this: fade out the old element first, then fade in the new one. That's the safest option for most use cases.
The mode in-out is the opposite: the new element fades in first, then the old one fades out. That sounds strange, but it's useful for slide transitions where the new element slides in from the side while the old one keeps sliding in the same direction at the same time. Without a mode, Vue Transition can sometimes be hard to predict for a key change on the same element, especially during rapid switches where a running enter transition gets interrupted by a new leave transition. Mode out-in completely prevents this race-condition effect.
5. TransitionGroup: list transitions without layout jumps
Vue TransitionGroup handles animations for list elements that change position, get added, or get removed. The feature that makes Vue TransitionGroup special is the FLIP animation (First, Last, Invert, Play): when an element changes its position in the list, Vue calculates the difference between the old and new position and animates the element with transform: translateX()/translateY() from the old to the new position. This happens without any layout animation at all, the browser only ever sees the final position, the animation is a pure transform animation.
FLIP in Vue TransitionGroup is controlled through the -move class: [name]-move { transition: transform 300ms ease; }. Without this class there is no FLIP animation, elements jump instantly to their new position. The tag attribute of Vue TransitionGroup defines the wrapper element in the DOM. The default is span, which is wrong for block elements, for list items tag="ul" or tag="div" should be set explicitly. Important: every child of Vue TransitionGroup must have a unique :key, without a key neither the transition nor FLIP will work correctly.
/* TransitionGroup with FLIP move transition */
/* All three types: enter, leave, move */
.list-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.list-leave-to {
opacity: 0;
transform: translateX(20px);
}
.list-enter-active,
.list-leave-active {
transition: opacity 250ms ease, transform 250ms ease;
}
/* FLIP: Vue calculates position delta and applies as transform */
/* Without this class: elements jump instantly to new position */
.list-move {
transition: transform 350ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Critical: leaving elements must be taken out of flow
during the move transition, otherwise FLIP breaks */
.list-leave-active {
position: absolute; /* remove from flow while leaving */
}
/* Usage in template:
<TransitionGroup name="list" tag="ul">
<li v-for="item in sortedItems" :key="item.id">...</li>
</TransitionGroup>
*/
6. Route transitions with Vue Router
Route transitions in Vue 3 with Vue Router are implemented through the <RouterView> slot. The pattern: <RouterView v-slot="{ Component }"> exposes the current route component as a slot variable, which is then wrapped in a <Transition> wrapper. The :key="route.fullPath" on the <component> tag ensures that Vue Transition fires even when only the query parameters change, without :key the same component instance stays around and the transition never activates.
A common mistake with route Vue transitions: placing the transition wrapper directly on <RouterView> instead of inside the slot. Vue Router 4 changed the API, the wrapper must sit inside the slot, not as a parent element. A second common mistake: implementing different route transitions depending on the navigation direction (forward/backward). That requires detecting the navigation direction in a router hook, storing it in a ref, and passing it in as a dynamic transition name. Coordinating router navigation with transition timing is the hardest part of this architecture.
7. JavaScript hooks for complex animations
Vue Transition supports JavaScript hooks as an alternative to pure CSS transitions: @before-enter, @enter, @after-enter and the corresponding leave hooks. Any JavaScript animation library can be used inside these hooks, GSAP, Anime.js, or the native Web Animations API. The @enter hook receives the DOM element as its first argument and a done callback function as its second. done() must be called once the animation finishes so Vue can correctly continue its lifecycle management. Without a done() call, the component gets stuck in the enter state.
The important attribute for JavaScript hooks is :css="false" on the <Transition> tag. This tells Vue not to set any CSS classes and not to wait for transitionend events either. Without :css="false", CSS classes and JavaScript animations overlap, which leads to undefined behavior. JavaScript hooks are especially useful for sequential animations (several elements one after another), physics-based animations (spring dynamics), and for animations that need state data from the Vue store, which isn't possible in CSS.
8. Performance pitfalls and how to spot them
The most common performance pitfall with Vue transitions is animating properties that trigger layout reflow. If "Layout" and "Recalculate Style" appear in the DevTools performance profiler during an animation, layout-triggering properties are being animated. The fix is almost always to use transform and opacity instead. A helpful resource is CSS Triggers (csstriggers.com), which documents which properties trigger reflow, repaint, or only composite. Only composite operations run smoothly on the GPU compositor.
The second performance pitfall is too many simultaneously animated elements in Vue TransitionGroup. FLIP is expensive with large lists because Vue calculates each element's position before and after the change. For lists with more than 100 elements, you should either use virtualization (vue-virtual-scroller) or disable the FLIP animation and only animate enter/leave. The third pitfall: transitions on elements that have children with complex painting. A shadow on an animated element creates a new layer composite on every frame update. will-change: transform promotes an element to its own compositor layer, which speeds up animations but costs memory, use it sparingly.
9. Comparison: smooth vs. janky
This comparison shows the most common causes of janky Vue transitions and the respective fix for smooth animations.
| Problem | Janky (flickers) | Smooth | Reason |
|---|---|---|---|
| Animate size | width/height transition | transform: scale() | scale() is compositor-only, no reflow |
| Animate position | top/left transition | transform: translate() | translate() causes no layout reflow |
| Element swap | No mode → both at once | mode="out-in" | No double layout |
| Reorder list | No -move class | TransitionGroup + FLIP | Transform animation instead of a jump |
| Route transition | Wrapper around RouterView (Vue Router 4) | RouterView v-slot + Transition | Correct API in Vue Router 4 |
A special case is transitions on modals with Vue Teleport. The modal's DOM gets teleported to body, but the Vue Transition classes are still applied correctly to the teleported element. That means Vue Transition and Vue Teleport are fully compatible and can be combined directly. The transition classes must be defined in a global CSS file or in component scoped CSS with :deep() if the modal element lives outside the scoped CSS context.
Mironsoft
Vue 3 UI quality and animation performance
Want to replace janky Vue transitions with smooth animations?
We analyze existing Vue animations for layout reflow issues and refactor them into compositor-only transitions, measurably smooth on every device.
Animation audit
DevTools performance analysis of all active transitions for layout reflow
FLIP integration
TransitionGroup with FLIP animations for smooth list sorting and filtering
Route transitions
Directional route transitions with navigation direction detection in Vue Router
10. Summary
Vue transitions are smooth when they exclusively animate opacity and transform, use the right mode, and rely on FLIP for list transitions. The key points: the -active class must always contain the transition declaration, otherwise no visible transition happens at all. Mode out-in is the safe default choice for element swaps. Vue TransitionGroup needs the -move class and position: absolute on -leave-active for correct FLIP animations. Route transitions in Vue Router 4 are defined in the v-slot of <RouterView>.
What flickers instead of being smooth: layout properties like width, height, margin, and top/left, which trigger layout reflow on every frame. Transitions without an explicit mode on element swaps, which lead to double layout. A missing -move class in Vue TransitionGroup, which makes elements jump. JavaScript hooks without :css="false", which collide with CSS classes. Anyone who knows these pitfalls and works with the right CSS properties builds Vue transitions that run smoothly on every device.
Vue Transitions, the key points at a glance
Smooth properties
Only animate opacity and transform. Scale instead of width/height. Translate instead of top/left. No layout reflow means 60fps on mobile devices.
CSS class structure
-from: starting state. -active: transition declaration (mandatory!). -to: target state (often the default). Without transition in -active: no visible transition.
TransitionGroup & FLIP
-move class for FLIP position animation. position:absolute on -leave-active. Unique :key on every child. Set tag explicitly to div or ul.
JavaScript hooks
:css="false" when using JS hooks. Always call the done() callback. For sequential animations, spring dynamics, and store-dependent animations.