Animations and Transitions
Animating floating surfaces feels simple until you apply a CSS transition to them. If you add transition: transform or a utility class like Tailwind's transition-all directly to a positioned element, it will fly in from the top-left corner of the screen when it mounts.
This happens because VFloat measures and positions the element after it enters the DOM. If the element itself transitions transform, the browser animates the jump from (0, 0) to its computed coordinates.
You do not need complex timing hacks to solve this. You just need to separate positioning from animation.
The core rule
Never transition the property VFloat uses to position the element.
By default, VFloat writes CSS transform: translate(x, y) to position the panel. If you transition transform on that same element, positioning calculations and CSS animations fight over the same property.
There are two clean ways to animate floating surfaces:
- The Nested Wrapper Pattern (Recommended): Put the positioning ref on an outer wrapper, and apply your Vue
<Transition>or CSS animation to an inner child element. - The
transform: falsePattern: Configure VFloat to position withtopandleft, leavingtransformcompletely free for scale and slide animations on a single element.
Pattern 1: The Nested Wrapper Pattern (Recommended)
This is the standard architecture used by headless UI libraries.
The outer wrapper receives the positioning styles from VFloat and snaps to the anchor without transitions. An inner container handles the enter and exit animations (such as scale, opacity, or slide).
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingNode, usePosition } from "v-float";
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(false);
const node = useFloatingNode({
anchorEl,
floatingEl,
open,
});
usePosition(node, {
placement: "bottom-start",
middlewares: {
offset: 8,
flip: true,
shift: { padding: 8 },
},
});
</script>
<template>
<button ref="anchorEl" type="button" @click="open = !open">Toggle Menu</button>
<!-- Vue Transition keeps the wrapper mounted while the leave animation finishes -->
<Transition name="pop" :duration="150">
<!-- Outer wrapper: receives positioning coordinates; NO transitions here -->
<div v-if="open" ref="floatingEl">
<!-- Inner element: handles scale, fade, and slide transitions -->
<div class="panel">
<p>Menu content</p>
</div>
</div>
</Transition>
</template>
<style scoped>
/* Target the inner element for animations */
.pop-enter-active .panel,
.pop-leave-active .panel {
transition:
opacity 150ms ease,
transform 150ms ease;
}
.pop-enter-from .panel,
.pop-leave-to .panel {
opacity: 0;
transform: scale(0.95);
}
</style>
Why :duration is required
Notice :duration="150" on <Transition>.
Because <Transition> surrounds an outer <div> that has no transition of its own, Vue needs to know when the nested .panel transition finishes before unmounting the outer wrapper from the DOM. Without :duration, Vue removes the element immediately on close, skipping the exit animation.
Pattern 2: Single element with transform: false
If you want to avoid an extra wrapper <div> and animate a single element directly, tell usePosition to position with top and left instead of transform:
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingNode, usePosition } from "v-float";
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(false);
const node = useFloatingNode({ anchorEl, floatingEl, open });
usePosition(node, {
placement: "bottom-start",
transform: false, // Positions with top/left instead of CSS transform
middlewares: {
offset: 8,
flip: true,
},
});
</script>
<template>
<button ref="anchorEl" type="button" @click="open = !open">Toggle Menu</button>
<Transition name="fade-scale">
<div v-if="open" ref="floatingEl" class="panel">Menu content</div>
</Transition>
</template>
<style scoped>
.fade-scale-enter-active,
.fade-scale-leave-active {
/* Safe: transform and opacity do not conflict with top and left */
transition:
opacity 150ms ease,
transform 150ms ease;
}
.fade-scale-enter-from,
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>
!WARNING Avoid
transition: allEven withtransform: false, do not usetransition: allor broad transition utility classes on the floating element. That causestopandleftto animate when repositioning or opening, creating awkward sliding artifacts. Explicitly specifytransition: opacity, transform.
The isPositioned lifecycle rules
usePosition returns an isPositioned boolean ref. A common mistake is to write v-if="isPositioned" or v-show="isPositioned" to delay rendering until coordinates are ready.
Do not use v-if="isPositioned" or v-show="isPositioned" to delay rendering.
v-if="isPositioned"leaves the outer element empty during Floating UI's measurement pass. Floating UI calculates placement based on a 0x0 box, producing incorrect coordinates or wrong side flips. OnceisPositionedbecomestrueand the child mounts, the layout expands abruptly and jumps.v-show="isPositioned"appliesdisplay: none, which also collapses the element's layout geometry to 0x0.
Valid uses of isPositioned
isPositioned is intended for side effects that must wait until the element is physically at its target coordinates:
- Focus management and scrolling:
watch(isPositioned, (positioned) => { if (positioned) { firstInputRef.value?.focus(); } }); - Gating CSS
@keyframesanimations: If using placement-dependent CSS keyframe animations that slide in from a specific side, suppress the animation until coordinates are ready so it does not fire from the wrong side:<div ref="floatingEl" :style="{ animation: isPositioned ? undefined : 'none' }" >
Placement-aware transitions
To animate a surface away from its trigger regardless of which side it flips to, bind the reactive placement returned by usePosition:
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingNode, usePosition } from "v-float";
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(false);
const node = useFloatingNode({ anchorEl, floatingEl, open });
const { placement } = usePosition(node, {
placement: "bottom-start",
middlewares: {
offset: 8,
flip: true,
},
});
</script>
<template>
<button ref="anchorEl" type="button" @click="open = !open">Toggle</button>
<Transition name="scale-origin" :duration="150">
<div v-if="open" ref="floatingEl" :data-placement="placement">
<div class="panel">Menu content</div>
</div>
</Transition>
</template>
<style scoped>
/* Transform origin shifts based on current placement side */
[data-placement^="bottom"] .panel {
transform-origin: top;
}
[data-placement^="top"] .panel {
transform-origin: bottom;
}
[data-placement^="right"] .panel {
transform-origin: left;
}
[data-placement^="left"] .panel {
transform-origin: right;
}
</style>
When flip changes the active placement from bottom to top, transform-origin automatically updates so the entrance animation originates from the trigger edge.
Where to go next
- Read Middlewares to configure boundary collision and sizing behavior.
- Read Virtual Anchors to position against pointer coordinates and synthetic elements.
- Inspect the
usePositionAPI Reference for all positioning parameters and return values.