Files
stack/packages/mosaic/framework/skills/vue-best-practices/reference/transition-js-hooks-done-callback.md
T
fargo 1a822493ba format: apply repo prettier (3.8.1) to the folded skills tree
963 markdown files reformatted with the repository's pinned prettier so
pnpm format:check covers the folded tree like every other repo file.

The formatter's embedded-language pass also normalized code fences
(TS semicolons, closed HTML tags in examples, lowercased CSS hex colors,
one renumbered list that skipped an index). Alphanumeric token deltas vs
the fold commit were audited file-by-file; all are formatter-equivalent
markup normalizations plus the four sanitized skills.
2026-08-19 14:37:17 -05:00

6.1 KiB

title, impact, impactDescription, type, tags
title impact impactDescription type tags
JavaScript Transition Hooks Require done() Callback with css="false" HIGH Without calling done(), JavaScript-only transitions complete immediately, skipping the animation entirely gotcha
vue3
transition
javascript
animation
hooks
gsap
done-callback

JavaScript Transition Hooks Require done() Callback with css="false"

Impact: HIGH - When using JavaScript-only transitions (with :css="false"), the @enter and @leave hooks must call the done() callback to signal when the animation completes. Without calling done(), Vue considers the transition finished immediately, causing elements to appear/disappear without animation.

This is especially important when using animation libraries like GSAP, Anime.js, or the Web Animations API.

Task Checklist

  • When using :css="false", always call done() in @enter and @leave hooks
  • Call done() when your JavaScript animation completes (in the onComplete callback)
  • Remember: done() is optional when CSS handles the transition, but required with :css="false"
  • Use :css="false" to prevent CSS rules from interfering with JS animations

Problematic Code:

<template>
  <!-- BAD: No done() callback - animation is skipped! -->
  <Transition :css="false" @enter="onEnter" @leave="onLeave">
    <div v-if="show" class="box">Content</div>
  </Transition>
</template>

<script setup>
import gsap from 'gsap';

function onEnter(el) {
  // Animation starts but Vue doesn't wait for it!
  gsap.from(el, {
    opacity: 0,
    y: 50,
    duration: 0.5,
  });
  // Missing done() call - element appears with no animation
}

function onLeave(el) {
  gsap.to(el, {
    opacity: 0,
    y: -50,
    duration: 0.5,
  });
  // Missing done() call - element removed immediately!
}
</script>

Correct Code:

<template>
  <!-- GOOD: done() callback signals animation completion -->
  <Transition :css="false" @enter="onEnter" @leave="onLeave">
    <div v-if="show" class="box">Content</div>
  </Transition>
</template>

<script setup>
import gsap from 'gsap';

function onEnter(el, done) {
  gsap.from(el, {
    opacity: 0,
    y: 50,
    duration: 0.5,
    onComplete: done, // Tell Vue animation is complete
  });
}

function onLeave(el, done) {
  gsap.to(el, {
    opacity: 0,
    y: -50,
    duration: 0.5,
    onComplete: done, // Element removed after animation
  });
}
</script>

Why Use :css="false"?

  1. Prevents CSS interference: Vue won't add transition classes that might conflict
  2. Slight performance benefit: Skips CSS transition detection
  3. Clearer intent: Makes it explicit that JS controls the animation
<template>
  <!-- Without :css="false", Vue adds v-enter-active etc. classes -->
  <!-- These can interfere with your JS animation timing -->
  <Transition @enter="onEnter" @leave="onLeave">
    <div v-if="show">May have CSS conflicts</div>
  </Transition>

  <!-- With :css="false", no classes added - full JS control -->
  <Transition :css="false" @enter="onEnter" @leave="onLeave">
    <div v-if="show">Pure JS animation</div>
  </Transition>
</template>

Complete JavaScript Transition Example

<template>
  <Transition
    :css="false"
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @after-enter="onAfterEnter"
    @enter-cancelled="onEnterCancelled"
    @before-leave="onBeforeLeave"
    @leave="onLeave"
    @after-leave="onAfterLeave"
    @leave-cancelled="onLeaveCancelled"
  >
    <div v-if="show" class="animated-box">Content</div>
  </Transition>
</template>

<script setup>
import gsap from 'gsap';
import { ref } from 'vue';

const show = ref(false);
let enterAnimation = null;
let leaveAnimation = null;

function onBeforeEnter(el) {
  // Set initial state before animation
  el.style.opacity = 0;
  el.style.transform = 'translateY(50px)';
}

function onEnter(el, done) {
  // Store animation reference for potential cancellation
  enterAnimation = gsap.to(el, {
    opacity: 1,
    y: 0,
    duration: 0.5,
    ease: 'power2.out',
    onComplete: done, // REQUIRED with :css="false"
  });
}

function onAfterEnter(el) {
  // Cleanup after enter completes
  enterAnimation = null;
}

function onEnterCancelled() {
  // Handle interruption (e.g., user toggles quickly)
  if (enterAnimation) {
    enterAnimation.kill();
    enterAnimation = null;
  }
}

function onBeforeLeave(el) {
  // Set state before leaving
}

function onLeave(el, done) {
  leaveAnimation = gsap.to(el, {
    opacity: 0,
    y: -50,
    duration: 0.5,
    ease: 'power2.in',
    onComplete: done, // REQUIRED with :css="false"
  });
}

function onAfterLeave(el) {
  leaveAnimation = null;
}

function onLeaveCancelled() {
  if (leaveAnimation) {
    leaveAnimation.kill();
    leaveAnimation = null;
  }
}
</script>

Using Web Animations API

<script setup>
function onEnter(el, done) {
  const animation = el.animate(
    [
      { opacity: 0, transform: 'scale(0.9)' },
      { opacity: 1, transform: 'scale(1)' },
    ],
    {
      duration: 300,
      easing: 'ease-out',
    },
  );

  animation.onfinish = done; // Call done when animation ends
}

function onLeave(el, done) {
  const animation = el.animate(
    [
      { opacity: 1, transform: 'scale(1)' },
      { opacity: 0, transform: 'scale(0.9)' },
    ],
    {
      duration: 300,
      easing: 'ease-in',
    },
  );

  animation.onfinish = done;
}
</script>

Common Mistakes

// WRONG: Calling done() immediately instead of after animation
function onEnter(el, done) {
  gsap.from(el, { opacity: 0, duration: 0.5 });
  done(); // Called immediately - animation skipped!
}

// WRONG: Forgetting done() parameter
function onEnter(el) {
  // No 'done' parameter
  gsap.from(el, {
    opacity: 0,
    onComplete: done, // Error: done is not defined!
  });
}

// CORRECT: Pass done to animation callback
function onEnter(el, done) {
  gsap.from(el, {
    opacity: 0,
    duration: 0.5,
    onComplete: done, // Called after 0.5s
  });
}

Reference