Who owns the end of the animation: done callbacks and the arity rule
CSS classes cover most transitions, but the moment you want a timeline, a spring, or a coordinated multi-element effect, you listen to the transition phases in JavaScript instead: @before-enter, @enter, @after-enter, @enter-cancelled, and the mirrored @before-leave, @leave, @after-leave, @leave-cancelled — plus @before-appear, @appear, @after-appear, @appear-cancelled for first render. Each hook receives the element; the enter and leave hooks also receive a done callback, and that callback is where interviewers aim most of their questions.
Vue decides how your hooks are timed by looking at your function's declared parameters — an implementation detail with contract-sized consequences:
// Vue keeps listening for the CSS end event (transitionend/animationend,
// or the explicit :duration timeout) and finishes the phase itself.
function onEnter(el) {
el.dataset.entered = 'yes'
}
// Vue stops listening for CSS entirely and waits for done().
// Call it exactly when YOUR animation finishes.
function onEnter(el, done) {
gsap.to(el, { opacity: 1, onComplete: done })
}
Declaring the second parameter transfers ownership of completion to you. With onEnter(el, done) Vue skips its CSS end detection, so if done never runs the phase never resolves: @after-enter does not fire, the enter classes are never cleaned off, and on the leave side the element is never removed from the DOM. With onEnter(el), Vue falls back to CSS timing and will even settle after an explicit :duration even if no event ever arrives. The callback is idempotent — calling done() twice is a no-op, so you can invoke it defensively.
JavaScript-only transitions get their own switch: :css="false" tells Vue to skip applying transition classes and skip CSS detection altogether. Then the same arity rule runs one level up — a one-parameter hook completes synchronously the moment it returns (the phase finishes immediately), and only a two-parameter hook keeps Vue waiting for done. That combination, :css="false" plus (el, done) handlers, is the standard shape for library-driven animation.
One surprise worth internalizing: enter-from-class and friends still apply during JavaScript transitions when you leave CSS enabled. Vue adds enter-from and enter-active before insertion, swaps to enter-to a frame later, and only the end detection is affected by your hook's signature — so a CSS class can hold the initial state (say, opacity: 0) while GSAP animates the rest, and you don't have to set starting styles imperatively.
| Hook | Fires | Signature |
|---|---|---|
@before-appear / @before-enter |
right before insertion (appear: initial mount only) | (el) |
@appear / @enter |
one frame after insertion, start the animation | (el, done) |
@after-appear / @after-enter |
after the phase completes | (el) |
@before-leave |
immediately when the leave is triggered | (el) |
@leave |
same tick, start the outgoing animation | (el, done) |
@after-leave |
after the phase completes and the node is removed | (el) |
@enter-cancelled / @leave-cancelled |
the phase was interrupted (@leave-cancelled: v-show only) |
(el) |
A few operational details round out the map. The hooks are ordinary listeners — @enter="onEnter" in a template, onEnter props in a render function — and several handlers can attach to the same phase, which Vue calls in registration order. Errors thrown inside a hook are routed through Vue's error handling (dev warnings, onErrorCaptured, the app-level error handler) instead of breaking the render. The same set applies per child inside <TransitionGroup>: @enter on the group fires once per inserted row, receiving that row's element — which is what makes index-staggered list animations possible at all.
Appear handlers fall back to their enter counterparts: register @enter alone and it also covers the first render under appear, because Vue substitutes onEnter for the missing onAppear. The cancellation hooks are the flip side of that safety: interrupt an enter by starting the leave, and Vue fires @enter-cancelled instead of @after-enter, so never assume @after-* runs on every path.
Driving a modal from GSAP and a spinner from requestAnimationFrame
Here is the shape of a real JavaScript-only transition: a modal whose open and close are timeline-driven, with cleanup for the case a user hammers the toggle button mid-animation.
<script setup>
import { ref } from 'vue'
import gsap from 'gsap'
const open = ref(false)
function onBeforeEnter(el) {
gsap.set(el, { opacity: 0, y: 24, scale: 0.96 })
}
function onEnter(el, done) {
gsap.to(el, {
opacity: 1, y: 0, scale: 1, duration: 0.35, ease: 'power2.out',
onComplete: done
})
}
function onLeave(el, done) {
gsap.killTweensOf(el) // an interrupted enter may still be mid-flight
gsap.to(el, {
opacity: 0, y: 12, duration: 0.25, ease: 'power2.in',
onComplete: done
})
}
function onLeaveCancelled(el) {
gsap.killTweensOf(el) // v-show re-shown mid-leave: stop the half-run tween
}
</script>
<template>
<button @click="open = true">Open</button>
<Transition :css="false"
@before-enter="onBeforeEnter" @enter="onEnter"
@leave="onLeave" @leave-cancelled="onLeaveCancelled">
<div v-show="open" class="modal-backdrop">…</div>
</Transition>
</template>
Three details make this robust rather than demo-grade. :css="false" removes Vue's CSS class handling from the picture, so GSAP is the only writer of styles and Vue's end detection cannot race it. onComplete: done ties GSAP's timeline end to Vue's phase end — which is what fires @after-enter, removes the modal's classes, and on the closing side lets v-show finish hiding the element. And killTweensOf in the cancelled path matters because the element persists: a 250ms leave that gets cancelled at 120ms leaves a half-faded tween alive, and the next open starts from garbage state without the kill (or without gsap.set in onBeforeEnter re-establishing a known start). A v-if modal gets the same benefit one step earlier — the kill in onLeave clears any interrupted enter tween before the close begins.
A library-free version is not much longer. Either let a CSS transition you started imperatively report its own end, or interpolate manually per frame:
function onEnter(el, done) {
el.style.opacity = '0'
el.offsetHeight // force style flush so the transition has a start value
el.style.transition = 'opacity 300ms ease'
el.style.opacity = '1'
el.addEventListener('transitionend', done, { once: true })
}
function onLeave(el, done) {
const start = performance.now()
const step = (now) => {
const t = Math.min((now - start) / 250, 1)
el.style.opacity = String(1 - t)
if (t < 1) requestAnimationFrame(step)
else done()
}
requestAnimationFrame(step)
}
The imperative-transition version needs the forced reflow (el.offsetHeight) between setting the start value and the target value — without a layout flush in between, the browser collapses both style writes into one and no transition ever starts. The rAF version shows the minimum contract plainly: every frame updates the style, and the final frame calls done — no library, no CSS classes, and @after-leave still fires at the right moment.
Keeping CSS enabled while animating from JavaScript is a legitimate hybrid: express the starting state in enter-from classes and let the library interpolate the rest, with Vue's CSS end detection still available as a safety net when your handler omits the done parameter. :css="false" takes the other trade — one writer of styles, one end signal, no stray CSS transition on a child element ending the phase early — and it is the right call once a timeline library is authoritative for motion in your app.
First paint is no different: appear swaps in the @appear hooks, and since those fall back to @enter/@leave when unregistered, the handlers above animate the modal's initial mount too if the element starts open. For list staggering — animating each row with a delay derived from its index — the same hook machinery hangs off <TransitionGroup> with :data-index attributes, the pattern you already have the vocabulary for after Animating Lists with TransitionGroup.
When @after-leave never fires: a checklist for stalled transitions
The signature bug of JavaScript transitions is a modal that fades to nothing and then sits in the DOM forever, invisible but still clickable. Vue is waiting for an end signal that never arrives, and almost always the cause is one of five things you can check in two minutes.
doneis declared but never called. The handler returns early on a guard (if (reducedMotion) return) or throws halfway through. Every exit path must reachdone()— wrap the body intry/finallywhen it can throw.- You declared
(el, done)but expected Vue to still watch CSS. The second parameter switches off Vue's end detection. If your CSS transition is the thing that actually animates, the signature lies about who owns completion; either calldonefrom atransitionendlistener or drop the parameter and let Vue watch the CSS. doneis called on success but not on the cancelled path. Phases can be interrupted: starting a leave fires@enter-cancelledinstead of@after-enter, and av-showpanel re-shown mid-leave fires@leave-cancelled(v-showonly — a keyedv-ifreplacement instead forces the leaving twin's early removal and completes its@after-leavenormally). Code that treats@after-*as the only completion signal breaks on exactly the fast-toggle interactions users test first.- The hook throws. Vue routes transition-hook errors through its error handling (dev warnings, your
onErrorCapturedor app-level error handler), so a failedgsap.todoes not crash the app — it just quietly strands the element. Catch, log, and calldoneanyway. - CSS and JavaScript both think they own the end. Mixing an infinite CSS animation with an undeclared duration, or pairing
:css="false"with a one-parameter hook (which completes synchronously — the animation is skipped, not just unwaited), produces phases that end too early or never.
Write the reduced-motion path correctly and half of those bugs cannot exist: a handler that checks matchMedia('(prefers-reduced-motion: reduce)') and calls done() immediately completes the phase in a frame — the content simply appears — while the same handler written as an early return produces exactly the frozen element this chapter opened with. try/finally around the animation call gives the same guarantee on error paths: whatever the library does, Vue still receives its signal.
Debugging it is mechanical: log each hook in order, confirm @before-leave → @leave fire, then inspect the element after the expected duration — if it is still document.body.contains(el) and carrying -leave-active classes, the leave phase never completed and your done chain is the prime suspect. If @after-leave fires but the element lingers, the bug moved: something re-inserted it, or you are watching a different node than the one that left.
The hooks being plain functions also makes them cheap to guard with tests. Extract onEnter and onLeave as named exports, call them in a unit test with a stub element and a done spy, and assert the signal fires — no mounted component, no real timers. A regression where a new branch skips done then fails one assertion instead of shipping an invisible, unclickable-but-still-present modal.
Where this lands in interviews: "how do you integrate an animation library with Vue?" earns points for the hook list, but the answer that wins is the contract — declaring done transfers completion responsibility, cancellation hooks exist because phases are interruptible, and @after-leave is a signal to react to, not a guarantee on every path. That discipline is also what makes these components testable: assert the hooks fire in order and that done resolves, which is exactly the kind of unit test you will write once you move on to Configuring Vitest.
Quiz
1. Your @enter handler is declared `function onEnter(el, done)` and never calls done. What happens?
2. What does `:css="false"` change on a Transition with JavaScript hooks?
3. You register only @enter and @after-enter, and you add the appear attribute. What runs on the component's first render?
4. A v-show panel is hidden, its 400ms leave reaches 120ms, and the user shows it again. Which hook announces the interruption?
Flashcards
Question
List the JavaScript transition hooks and when each fires.
Click to reveal answer
Answer
Enter: @before-just before insertion, @enter one frame after insertion (receives el and done), @after-enter on completion, @enter-cancelled when interrupted. Leave mirrors it: @before-leave when the leave triggers, @leave (el, done), @after-leave after the node is removed, @leave-cancelled (v-show only). Appear: @before-appear, @appear, @after-appear, @appear-cancelled — initial insertion only.
Question
What is Vue's arity rule for transition hooks, and what bug does it cause?
Click to reveal answer
Answer
Vue inspects the handler's declared parameters: an (el) handler keeps Vue watching for the CSS end event, while an (el, done) handler switches that off — Vue waits for you to call done. Declaring done and forgetting to call it (guard return, exception, skipped branch) stalls the phase: no @after-* event and, on leave, an element that never leaves the DOM.
Question
What does `:css="false"` do, and what must you provide with it?
Click to reveal answer
Answer
It tells Vue to skip applying transition classes and skip CSS end detection, so JavaScript fully owns timing. With it, two-parameter enter/leave hooks must call done or the phase finishes immediately/synchronously (one-parameter hooks complete as soon as they return).
Question
How do appear hooks relate to enter hooks?
Click to reveal answer
Answer
They fall back to them: an unregistered @before-appear uses @before-enter, @appear uses @enter, @after-appear uses @after-enter, and @appear-cancelled uses @enter-cancelled. Registering only enter handlers gives you first-paint animation for free under the appear attribute.
Question
Which transition hook is v-show-only, and what happens on an interrupted keyed v-if instead?
Click to reveal answer
Answer
@leave-cancelled fires only in persisted (v-show) mode, when the element is re-shown mid-leave. With keyed v-if, re-entering while the old twin is leaving force-removes that twin early and completes its @after-leave normally — no cancellation hook — so completion logic must cover both.
Revision Notes
Key Takeaways
- 1. Every phase has three JS events plus a cancelled variant; enter and leave hooks receive a done callback that ends the phase
- 2. Vue decides who owns completion from your function's signature — (el, done) disables CSS end detection, (el) leaves it on
- 3. An uncalled done never resolves the phase: no @after-enter, no class cleanup, and on leave the element stays in the DOM forever
- 4. :css="false" is the correct flag for library-driven animation — no classes, no sniffing, JavaScript owns all timing
- 5. Cancellation is real: -cancelled hooks replace @after-* on interrupted phases, so animation libraries need killTweensOf-style cleanup on both paths
Interview Tips
- • "How do you integrate GSAP with Vue transitions?" — :css="false", (el, done) handlers, gsap.to(..., onComplete: done), and cleanup with killTweensOf on the cancelled paths; then explain that declaring done is what makes Vue stop watching for CSS events
- • "What happens if I forget to call done?" — the phase never completes: @after-enter never fires and a leaving element is never removed; add that done is idempotent so defensive double-calls are safe
- • "Difference between CSS and JavaScript transitions in Vue?" — CSS: Vue applies six classes and detects end via transitionend/animationend or explicit duration; JS: hooks fire at the same points, but completion follows your done callback or, with one-parameter hooks, synchronous return
- • "When do cancelled hooks fire?" — entering interrupted by a leave fires @enter-cancelled; @leave-cancelled is v-show only, while keyed v-if replacements force the leaving node's removal and fire @after-leave — knowing the asymmetry signals real experience
Cheat Sheet
JavaScript Transition Hooks
| Hook | Timing | Signature |
|---|---|---|
@before-enter / @before-appear |
before insertion | (el) |
@enter / @appear |
one frame after insertion | (el, done) |
@after-enter / @after-appear |
phase completed | (el) |
@before-leave |
leave triggered | (el) |
@leave |
leave starts | (el, done) |
@after-leave |
completed and node removed | (el) |
@enter-cancelled / @leave-cancelled |
phase interrupted (leave-cancelled: v-show only) |
(el) |
The arity rule (the whole contract):
function onEnter(el) { /* Vue still waits for CSS end / :duration */ }
function onEnter(el, done) { gsap.to(el, {..., onComplete: done}) } // YOU own completion
Declaring done disables Vue's CSS end detection. Uncalled done = phase stalls = element stranded in the DOM.
JS-only setup: <Transition :css="false" @enter="onEnter" @leave="onLeave"> — no classes applied, no CSS sniffing; done required.
Still true with CSS enabled: enter-from-class etc. apply during JS hooks (initial state can live in CSS); appear hooks/classes fall back to enter; done is idempotent; interrupted phases fire the -cancelled hooks instead of @after-*.