Two small built-in components that solve problems disproportionate to their size.
Transition
Wrap an element whose appearance or disappearance you want animated. Vue does not animate anything itself — it adds and removes classes at the right moments and lets CSS do the work:
<Transition name="fade">
<p v-if="show">Hello</p>
</Transition>.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}Six classes, named from the name prop:
ENTERING
fade-enter-from one frame, at the start
fade-enter-active for the whole transition -- put `transition:` here
fade-enter-to one frame after start, removed when it finishes
LEAVING
fade-leave-from
fade-leave-active
fade-leave-toThe mental model that makes this click: -from is where it starts,
-to is where it ends, and -active is the rule that describes the journey.
Enter and leave are usually mirror images, so -enter-from and -leave-to hold
the same declarations — which is why they are so often written as one selector list.
<Transition> takes exactly one child and it must be toggled by
something — v-if, v-show, a dynamic <component>, or a
changing key. Nothing happens without that.
Vue works out the duration by reading the CSS, so there is no number to keep in sync between the stylesheet and the JavaScript.
TransitionGroup
For a list whose items come and go. The demo application's toast stack is a complete example:
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1090">
<TransitionGroup name="toast">
<div
v-for="t in items"
:key="t.id"
class="toast show align-items-center border-0 mb-2"
:class="`text-bg-${t.variant}`"
role="alert"
aria-live="polite"
>
<div class="d-flex">
<div class="toast-body">{{ t.message }}</div>
<button
type="button"
class="btn-close btn-close-white me-2 m-auto"
aria-label="Dismiss"
@click="toast.dismiss(t.id)"
></button>
</div>.toast-enter-active,
.toast-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(18px);
}
</style>Three differences from <Transition>:
It takes many children, and every one needs a :key —
this is where lesson 8's warning about index keys becomes visible rather than theoretical. Dismiss the
middle toast with index keys and the wrong one animates out.
It renders no wrapper by default. Add tag="ul" if you need one.
It gives you a -move class for the items that shuffle to fill a gap:
.toast-move {
transition: transform 0.24s ease;
}That one is worth adding whenever a list reorders. Without it, remaining items jump to their new positions instantly while the removed one fades — which looks broken in a way people notice without being able to say why.
Leaving takes the element out of flow
A leaving element is still in the DOM. For a smooth -move, take it out of the layout so
the others can slide up immediately:
.toast-leave-active {
position: absolute;
}Teleport
<Teleport> renders its content somewhere else in the DOM while leaving it in the
component tree exactly where it was.
<Teleport to="body">
<div
v-if="confirmDelete"
class="position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center p-3"
style="background: rgba(0, 0, 0, 0.6); z-index: 1080"
@click.self="confirmDelete = null"
>
<div class="reel-surface p-4" style="max-width: 420px" role="dialog" aria-modal="true">The dialog markup is written where it belongs — inside the reel list, next to the state and handlers
it uses — but it renders as the last child of <body>.
Everything else is unchanged. confirmDelete, @click.self, the scoped styles
and the emitted events all still belong to ReelListView. It is only the DOM position that
moves.
Why an overlay needs this
The reason is subtle enough to be worth stating precisely, because position: fixed sounds
like it should already be immune to its ancestors.
It is not. A transform, filter, perspective,
backdrop-filter, will-change or contain on any
ancestor makes that ancestor the containing block for fixed descendants. The overlay is then positioned
and clipped relative to it rather than the viewport — so a full-screen backdrop covers a card instead of
the screen, and z-index cannot rescue it, because the ancestor also created a stacking
context.
Nothing in the demo application's admin layout does that today. The comment on the teleport says so.
The failure arrives the day someone adds a hover transform to a wrapper three levels up, and it presents
as a bug in the modal rather than in the thing that actually changed. Teleporting to
<body> means there are no ancestors left to get it wrong.
The details
to takes a CSS selector or an element. The target must exist when the teleport
mounts — teleporting into another Vue component's element is a race you will lose.
<body> always exists, which is why it is the usual answer.
:disabled="true" renders in place instead, which is how one component serves as an inline
panel on desktop and a full-screen sheet on mobile.
Several teleports to the same target append in mount order.
What it does not do
Teleport moves the DOM. It does not make a dialog accessible — that is
role="dialog", aria-modal="true", a focus trap, restoring focus on close and
Escape to dismiss. The demo application's overlay has the roles and the click-outside; a production
dialog wants the rest, and the native <dialog> element now gives you focus trapping
and Escape for free.