In Vue 3, how do you animate a `<Transition>` with JavaScript hooks, and what do the `done` callback and `:css="false"` change?
answer
- enter and leave receive el and done
- declaring done means you end it
- skip classes and CSS detection
- a forgotten done strands the element
basics
~20 sVue 3's Transition emits before-enter, enter, after-enter, before-leave, leave and after-leave. Enter and leave receive the element and a done callback; setting css to false skips classes and CSS detection, so calling done is what ends the phase.
solid answer
~40 s`<Transition>` emits `@before-enter`, `@enter`, `@after-enter`, `@enter-cancelled`, the matching `leave` events (`@leave-cancelled` fires only with `v-show`) and `@appear` variants. `@enter` and `@leave` receive `(el, done)`. Vue checks how many parameters the handler declares: if it declares `done`, Vue stops watching for CSS end events and waits for you to call `done()`. If it takes only `el`, Vue ends the phase itself — through CSS detection normally, or immediately when `:css="false"`. `:css="false"` tells Vue not to add enter/leave classes or read computed styles, so stray CSS cannot interfere. The trap: a leave handler that declares `done` and never calls it leaves the element in the DOM forever.
code
vue · 38 lines<script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
let running: Animation | undefined
function onEnter(el: Element, done: () => void) {
running = el.animate(
[
{ opacity: 0, transform: 'scale(0.95)' },
{ opacity: 1, transform: 'scale(1)' },
],
{ duration: 180, easing: 'ease-out' },
)
running.onfinish = () => done()
}
function onEnterCancelled() {
running?.cancel()
}
function onLeave(el: Element, done: () => void) {
running = el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120 })
running.onfinish = () => done()
}
</script>
<template>
<button @click="open = !open">Toggle panel</button>
<Transition
:css="false"
@enter="onEnter"
@enter-cancelled="onEnterCancelled"
@leave="onLeave"
>
<div v-if="open" class="panel">Panel content</div>
</Transition>
</template>go deeper
Remember that enter and leave get the element plus a done callback, and that done must be called when the animation finishes.
Explain how the handler's parameter count decides who ends the phase, and what :css="false" removes from Vue's work.
Anticipate stuck leaves from a missed done, handle enter-cancelled so animations do not fight, and watch for default parameters hiding done.
Decide when JavaScript-driven transitions are worth their extra failure modes over class-based CSS, and wrap them in one reusable transition component.
## The hook events Besides CSS classes, `<Transition>` emits events at each point of the enter and leave phases. You listen with `v-on` like any component event, typically pointing at functions in `<script setup>`. | Event | Arguments | When it fires | |---|---|---| | `@before-enter` | `el` | before the element is inserted | | `@enter` | `el`, `done` | right after insertion, to start the enter | | `@after-enter` | `el` | when the enter completes | | `@enter-cancelled` | `el` | a leave starts before the enter finished | | `@before-leave` | `el` | when a removal is triggered | | `@leave` | `el`, `done` | to start the leave | | `@after-leave` | `el` | after the leave completes and the element is removed | | `@leave-cancelled` | `el` | only with `v-show`, when the element re-enters mid-leave | With the `appear` prop, the initial render emits `@before-appear`, `@appear`, `@after-appear` and `@appear-cancelled`, which default to the enter handlers when you do not set them. ## Who ends the phase: the `done` rule `@enter` and `@leave` are the two asynchronous hooks. Vue decides whether **you** or **it** ends the phase by looking at the handler's declared parameter count (`Function.length`): 1. **Handler declares `(el, done)`.** Vue treats `done` as an explicit callback. It does not wait for `transitionend` or `animationend`, and the phase ends only when you call `done()`. 2. **Handler declares only `(el)`, CSS enabled.** Your code runs, and Vue still ends the phase from the CSS classes' computed transition or animation. 3. **Handler declares only `(el)`, `:css="false"`.** There is no CSS to detect, so Vue calls `done` right after your handler returns — the phase ends immediately, before any JavaScript animation you started has played. For leave, "ending the phase" is what removes the element from the DOM and fires `@after-leave`. For enter, it removes the enter classes and fires `@after-enter`. ## What `:css="false"` does By default the JavaScript hooks run **alongside** the class machinery: Vue still adds `v-enter-from`, `v-enter-active` and the rest, and still reads computed styles. `:css="false"` switches that off entirely: - no enter/leave/appear classes are added; - no `getComputedStyle` read or end-event listener is set up; - the hooks are passed straight through, so `done` is the only completion signal. The Vue guide recommends it for JavaScript-only transitions for two reasons: it is slightly cheaper, and it stops an unrelated CSS rule — a global `transition: all` on the element, for instance — from making Vue wait on, or cut short, your animation. ## A JavaScript-driven transition The code example uses the platform's Web Animations API (`el.animate()`), which returns an `Animation` whose `onfinish` callback is the natural place to call `done`. The same shape works with any animation library: start the animation in `@enter` / `@leave` and call `done` in its completion callback. ## Pitfalls - **Forgotten `done` on leave.** The element is never removed, `@after-leave` never fires, and with `mode="out-in"` the next view is never inserted because it waits on that leave. - **Parameter count by accident.** `function onLeave(el, done = () => {})` or a rest parameter `(...args)` reports a `Function.length` below 2, so Vue treats the handler as if it had no `done` — with `:css="false"` the phase ends immediately. - **Cancellation.** If the user closes a panel mid-enter, `@enter-cancelled` fires and the leave starts. A JavaScript enter should keep its animation handle and cancel it there, or the two animations fight over the same properties. - **Setting initial state.** Use `@before-enter` to put the element in its starting state before it is painted, so the first frame does not flash the final state. ## Mixing CSS and JavaScript Hooks and classes are not mutually exclusive. A common pattern keeps the fade in CSS classes and uses `@after-enter` to move focus into a panel once it is fully visible, or `@before-leave` to record the element's position. Those hooks take only `el`, so the CSS timing still decides when the phase ends. In short: declare `done` when you own the timing, call it on every path, and add `:css="false"` when there is no CSS side at all.
- What happens if a `@leave` handler declares `done` but an early return skips calling it?The leave never completes. Vue removes the element only inside the `done` callback, so the node stays in the DOM, `@after-leave` never fires, and with `mode="out-in"` the next view is never inserted because it waits on that leave. Call `done` on every path, including error handling.
- When does `@enter-cancelled` fire, and what should a JavaScript hook do with it?It fires when the element starts leaving before its enter finished — the user closes a panel mid-animation. Vue then runs the leave hooks. A JavaScript enter should keep a handle to its running animation and stop it in `@enter-cancelled`, otherwise enter and leave fight over the same properties. `@leave-cancelled` is the mirror and fires only with `v-show`.
saying these in an interview costs you the question
- With :css="false" Vue still adds v-enter-active so you can style it.
- Vue always waits for transitionend, even when the enter handler declares done.
- Vue removes a leaving element after a default timeout if done is never called.
- JavaScript hooks only run when CSS classes are disabled.
- A one-argument @enter handler with :css="false" waits for its animation to finish.