Vue – Transitions and Teleport

December 14, 20254 min readUpdated 8/24/2026

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-to

The 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.

Next: Async Components, Suspense and KeepAlive.