skip to content

A Vue 3 toast component has `<Transition>` as its template root and the parent renders it with `v-if`; why does neither the fade-in nor the fade-out play, and how do you fix it?

level: seniorimportance: should knowfreq 40%

answer

  1. what actually triggers a Transition
  2. the wrapper unmounts with its child
  3. first render needs appear
  4. keep Transition mounted, move the toggle

basics

~20 s

Vue 3's Transition animates its slot content changing, not its own mount or unmount. The parent's v-if creates and destroys the Transition with the toast, so enter is an unanimated first render and leave is skipped.

solid answer

~40 s

`<Transition>` only animates a child being inserted into or removed from an already-mounted `<Transition>`. When the parent's `v-if` mounts the toast, the `<Transition>` mounts too, which is an initial render — no enter unless `appear` is set. When the `v-if` goes false, the whole component, `<Transition>` included, unmounts; Vue marks the transition as unmounting and removes the child without running leave hooks. Fix it by keeping `<Transition>` mounted: pass visibility in as a prop and put the `v-if` inside the toast, or move `<Transition>` into the parent around `<Toast v-if>`. In the second form the toast needs a single element root, or Vue warns that the component renders a non-element root node that cannot be animated.

code

vue · 14 lines
vue
<!-- Toast.vue: used as <Toast :show="saved" message="Saved" @dismiss="saved = false" /> -->
<script setup lang="ts">
defineProps<{ show: boolean; message: string }>()
const emit = defineEmits<{ dismiss: [] }>()
</script>

<template>
  <Transition name="toast" appear>
    <div v-if="show" class="toast" role="status">
      {{ message }}
      <button @click="emit('dismiss')">Dismiss</button>
    </div>
  </Transition>
</template>

go deeper

for a junior

Remember that the Transition must already be on the page, with the v-if or v-show inside it, for enter and leave to play.

for a middle

Explain why the initial render needs appear and why an unmounting Transition removes its child without leave hooks.

for a senior

Spot the wrapper-root pattern in ported Vue 2 code, choose between a visibility prop and a parent-side Transition, and check the single-root constraint.

for a principal

Decide whether reusable overlays own their motion or leave it to callers, and make that a documented component-API convention.

## The rule: a Transition animates its children, not itself `<Transition>` is a component with state of its own: it remembers whether it has mounted and which child it rendered last. Enter and leave animations are the result of that child **changing** while the `<Transition>` stays in the tree. So the question to ask of any transition that does not play is: *is the `<Transition>` still mounted when its child appears or disappears?* In the broken toast, the component's template is: ```vue-html <Transition name="toast"> <div class="toast">Saved</div> </Transition> ``` and the parent writes `<Toast v-if="saved" />`. The `v-if` is **outside** the `<Transition>`, on the component that contains it. ## What happens on mount When `saved` becomes true, Vue creates the `Toast` component, which creates its `<Transition>`, which renders its `div` — all in one go. From the `<Transition>`'s point of view this is its **initial render**, and by default `<Transition>` does not animate the initial render. The enter classes are never added. Adding `appear` changes exactly this: the initial render is treated as an enter. That would make the fade-in play, but only the fade-in. ## What happens on unmount When `saved` becomes false, Vue unmounts the `Toast` component and everything inside it. Before unmounting, `<Transition>` records that it is itself unmounting; when its child's leave is then requested, it removes the child **immediately** and runs no leave hooks or classes. It has to: an animation needs a mounted parent to keep the leaving node in the DOM while it plays, and that parent is going away in the same update. The fade-out therefore never plays, and no warning is logged. ## Two fixes 1. **Toggle inside the toast.** Keep the component mounted and pass visibility in as a prop (or a `v-model`). The `v-if` moves onto the `div` inside the `<Transition>`, so the `<Transition>` stays mounted while its child enters and leaves. Add `appear` too if the toast can already be visible when it first mounts. 2. **Transition in the parent.** Leave the component unaware of animation and write `<Transition name="toast"><Toast v-if="saved" /></Transition>` in the parent. The `<Transition>` now lives in the parent's tree and outlives the toast. | | Toggle inside | Transition in parent | |---|---|---| | Who owns the animation | the toast | each caller | | Parent API | a visibility prop | plain `v-if` | | Root-element constraint | none on the toast | toast needs one element root | | Best for | a reusable component with built-in motion | one-off wrapping of existing components | ## The single-root requirement In fix 2 the `<Transition>`'s child is a **component**, and the classes and hooks are applied to that component's **root element**. So the component must render exactly one element at its root. If it renders: - a **fragment** (two or more top-level nodes), or - only **text**, there is no single element to put `toast-enter-from` on, and in development Vue warns: `Component inside <Transition> renders non-element root node that cannot be animated.` A root that is a single element with its own `v-if` is fine; when false it renders a comment placeholder, which Vue accepts. ## Diagnosing it in practice - Watch the element in the browser's inspector as it disappears. If `toast-leave-active` never appears and the node vanishes at once, the leave was skipped rather than badly styled. - Find the nearest `<Transition>` above the node and check whether the condition that removes the node sits **inside** it or **above** it. - Check any wrapper component whose template root is a `<Transition>`: that is the pattern that looks right and cannot work in Vue 3. The Vue 2 behaviour is what makes this a senior question: in Vue 2 a `<transition>` at a component's root did animate when the component itself was toggled, by accident rather than design. Vue 3 removed that quirk, so ported toasts, modals and dropdowns lose their animations without any error. ## The same pattern elsewhere The rule is not specific to toasts. Any wrapper whose template root is `<Transition>` — a dropdown menu, an alert banner, a confirmation modal — breaks the same way when the caller toggles the wrapper instead of passing it a visibility prop.

  • Would adding `appear` to the original toast's `<Transition>` fix it?
    Only half of it. `appear` makes the child animate when the `<Transition>` first mounts, so the fade-in would play each time the parent's `v-if` creates the toast. The fade-out still cannot play: the `v-if` destroys the `<Transition>` together with its child, and Vue skips leave hooks for a transition that is itself unmounting.
  • The parent writes `<Transition><Toast v-if="saved" /></Transition>` and Vue warns about a non-element root node. What is wrong?
    `<Transition>` applies its classes and hooks to the child component's root element, so that root must be one element. If `Toast` renders a fragment of several top-level nodes, or only text, there is nothing to put `v-enter-from` on and Vue warns `Component inside <Transition> renders non-element root node that cannot be animated.` Wrap the toast's template in a single element.

saying these in an interview costs you the question

  • A v-if on a component whose root is a Transition triggers its enter and leave.
  • Adding appear fixes both the fade-in and the fade-out.
  • Transition can animate a multi-root component by animating each root node.
  • Leave hooks run whenever the element is removed, however it is removed.
  • Vue logs a warning when a leave transition is skipped.