This article is published in English.
Fixing Vue Height Transitions for Asynchronously Rendered Web Components
Learn why a Vue expand transition animates from 0px to 0px around lazily upgraded Web Components, and how ResizeObserver delays the animation until real height exists.
A reusable expand/collapse component built on Vue's <Transition> usually measures an element's height in the enter hook and animates from zero to that value. That works well for native elements and ordinary Vue components, but it can fail silently when the child is a dynamically loaded Web Component: the panel appears with no animation at all. This guide explains the timing problem behind that failure, why polling is only a partial fix, and how to use ResizeObserver so the transition starts exactly when the content has a real size.
Why the animation runs from 0px to 0px
Suppose error messages are rendered by a notification element implemented as a custom element and wrapped in the expand transition:
<transition-expand>
<notification-wrapper v-if="showError" />
</transition-expand>
When showError flips to true, Vue inserts <notification-wrapper> into the DOM and calls the transition's enter hook straight away. For a Vue component that is fine, because its markup already exists by then. Many Web Components, however, are upgraded asynchronously: the tag lands in the DOM as an empty, unknown element, and only later, once its definition has loaded and its shadow DOM has rendered, does it gain content and height.
The real order of events looks like this:
v-if becomes true
↓
Vue inserts the element
↓
Transition enter() runs
↓
Height is still 0px
↓
Web Component finishes rendering
↓
Actual height becomes 160px
The transition takes its measurement at step three, while the element is still an empty shell. It dutifully animates from 0px to the measured 0px, and a moment later the component renders at its full 160px with no transition. To the user it looks as though the animation never ran.
The polling workaround and its cost
The obvious fix is to wait until the element reports some height before measuring. A loop placed ahead of the height calculation does that by checking once per frame:
while (element.scrollHeight === 0) {
await new Promise(requestAnimationFrame)
}
This does work. Each iteration awaits the next animation frame and asks again whether scrollHeight is still zero. The drawback is that it repeatedly queries layout every frame until something changes, and if the content never gains height, for example because the component fails to load, the loop keeps running for as long as the element exists. It also turns enter into an async function, which makes the hook harder to reason about. The browser already has a notification mechanism for exactly this situation, so there is no need to ask it the same question sixty times a second.
Separating measurement from animation
Before switching to an observer, it helps to restructure the transition code slightly. In a typical implementation, enter() both measures the element and runs the animation. Now that the measurement might have to wait, those two jobs are better kept apart.
The animation code itself stays exactly as it was; it simply moves into a helper named animateEnter(). The new enter() then has one responsibility, deciding when to start:
- When the element can already be measured, call
animateEnter()immediately. - Otherwise, wait until it does, and then call
animateEnter().
This refactor changes no behavior for the common case, and it gives the waiting logic a clear, isolated home.
Waiting for real height with ResizeObserver
ResizeObserver reports when an element's size changes, which is precisely the signal needed here. With the animation extracted, the new enter() becomes short:
function enter(element: HTMLElement) {
if (element.scrollHeight > 0) {
animateEnter(element)
return
}
const observer = new ResizeObserver(() => {
if (element.scrollHeight === 0)
return
observer.disconnect()
requestAnimationFrame(() => {
animateEnter(element)
})
})
observer.observe(element)
}
Walk through it in order. The fast path handles native elements and already-rendered components: if scrollHeight is positive, the animation starts right away, just as before. Otherwise an observer is attached to the element. Its callback checks the height again and returns early while it is still zero; this guard matters because the observer also fires once as soon as observation begins, when the element may still be empty. Once real height appears, the observer disconnects itself, so it does not keep firing on later size changes, and the animation starts on the next animation frame, giving the browser a chance to settle layout before the transition begins.
Only the moment the animation starts has changed. The animation itself is untouched.
Edge cases to handle in production
A few situations are worth covering depending on how the component is used:
- If the element is removed or hidden before it ever gains height, the observer is still attached. Disconnecting it in the transition's leave or cancellation handling avoids a lingering observer.
- The observer reacts to the size of the element it is watching. If your
beforeEnterstep pins the element toheight: 0, confirm that the observed element can actually change size in that state, or observe the inner content instead. - When a transition is driven entirely by JavaScript hooks, Vue expects you to signal completion through the
donecallback passed toenter. Make sure that signal still fires after a delayed start.
Why the observer approach is more robust
Compared with polling, the ResizeObserver version has several advantages:
- There is no per-frame loop checking layout.
- It handles Web Components that upgrade asynchronously.
- It also handles lazily loaded Vue components.
- It copes with images and other content whose size changes after the first render.
- The transition stays generic, with no knowledge of what it wraps.
That last point is the most valuable. The expand component does not need to know whether its child is a native div, a Vue component or a custom element; it holds off until the wrapped content reports a nonzero size, then runs the animation.
Wrapping up
The original measure-then-animate approach remains perfectly adequate for most Vue applications, where content is laid out by the time enter runs. The adjustment matters when rendering is asynchronous and layout is not ready at the moment Vue inserts the element. The broader lesson for reusable UI primitives is to avoid assuming when layout becomes available: react to the browser's own signal instead. It is a small code change that noticeably improves reliability, and each edge case like this one found in a real integration makes the component sturdier for every project that uses it next.