Teleport in Vue.js
Teleport is a built-in Vue 3 component that renders its content into any DOM node you choose, completely outside the component tree that contains it.
Theory
TL;DR
- Teleport is like mail forwarding: you write the content in one place, Vue delivers the DOM to a different address
- Content moves to the
totarget but stays Vue-managed (reactive, props, events, provide/inject all work normally) - The Vue component tree does NOT change, only the DOM position changes
- Use it when parent CSS (overflow, z-index stacking context) breaks overlay layouts
- Skip it when
position: fixedalone solves the problem without style conflicts
Quick example
<template>
<button @click="show = true">Open</button>
<Teleport to="body">
<div v-if="show" class="modal" @click.self="show = false">
Click outside to close
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(false)
</script>
<style scoped>
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.5); }
</style>The modal renders as a direct child of <body>, not inside the button's parent component. The v-if and the click handler still react to show from the original component scope.
Key difference
Teleport moves the actual rendered DOM to your to selector during mount. The component instance and reactivity stay in the original Vue tree. So v-model, props, events, and provide/inject all work as expected. CSS inheritance, though, stops at the new target's parent, which means the teleported content is free from whatever styles the original parent had.
When to use
- Modals and dialogs:
to="body"sidesteps z-index problems caused by parent stacking contexts - Tooltips and popovers: escapes
overflow: hiddenclipping in ancestor containers - Notifications:
to="#notifications"stacks them without affecting layout flow - Rich-text editor dropdowns: avoids toolbar menus being clipped by the editor wrapper
Most z-index nightmares I have seen in Vue dashboards came from a modal stuck inside a card component with overflow: hidden. One <Teleport to="body"> and the problem was gone. Skip Teleport when position: fixed alone handles it.
How Vue handles Teleport internally
During mount, Vue's renderer extracts the Teleport's child VNodes, calls document.querySelector(to) to find the target, then appends the real DOM with appendChild or insertBefore. Reactivity updates still target the relocated nodes through an internal vnode flag. On unmount, Vue removes the DOM from the target automatically. Setting :disabled="true" skips relocation entirely and renders content inline.
Common mistakes
1. Teleporting to a selector that does not exist
<!-- Wrong: content becomes invisible -->
<Teleport to=".missing-class">
<div class="modal">...</div>
</Teleport>querySelector returns null and the content renders inline but may be completely invisible. Use body or a guaranteed element. This is the most reported Teleport issue on StackOverflow.
2. Expecting scoped styles to follow teleported content
<!-- Wrong: .modal style has no effect after teleport -->
<Teleport to="body">
<div class="modal">...</div>
</Teleport>
<style scoped>
.modal { opacity: 1; } /* CSS stays scoped to original mount point */
</style>Scoped styles add a data-v-xxxxx attribute to both the DOM node and the CSS selector. The teleported node keeps the attribute, but body-level styles can override yours. Fix: use a global <style> block or :deep() for anything that needs to apply after relocation.
3. Forgetting SSR
On the server there is no body to query. Vue renders Teleport content inline during SSR. If the client then teleports, you get a hydration mismatch error. Disable Teleport until after mount:
<script setup>
import { ref, onMounted } from 'vue'
const isMounted = ref(false)
onMounted(() => { isMounted.value = true })
</script>
<template>
<Teleport to="body" :disabled="!isMounted">
<div class="toast">Saved!</div>
</Teleport>
</template>4. Nested Teleports without disabled in SSR
An inner Teleport resolves its to selector relative to the outer Teleport's DOM target, not the original template position. In SSR this creates hydration errors. Disable inner Teleports on the server the same way as above.
Real-world usage
- Vuetify 3: all
v-dialogandv-menucomponents use<Teleport to="body"> - Element Plus:
ElMessageandElNotificationteleport to a custom#approot - Naive UI:
n-messageappends directly todocument.body - Quasar:
QDialoguses a body target under the hood
React has createPortal for the same purpose, but it requires manual cleanup. Vue Teleport removes the DOM automatically when the component unmounts.
Follow-up questions
Q: What is the difference between Teleport and position: fixed?
A: position: fixed still respects the parent stacking context when the parent has transform, filter, or will-change set. Teleport physically moves the DOM to the target element, so only that target's stacking context applies.
Q: How does Teleport handle multiple matches for the to selector?
A: It appends to the first element returned by querySelector. Use unique IDs when you need predictable placement.
Q: What happens during SSR?
A: Vue renders Teleport content inline on the server. On the client it relocates the DOM during mount. Tie :disabled to an onMounted flag to keep server and client output in sync.
Q: Does Teleport affect performance?
A: Minimal. Each Teleport does one DOM move at mount time. Avoid creating hundreds of them separately; batch things like notifications into one shared target container instead.
Q: (Senior) Why does a nested Teleport resolve to relative to the outer Teleport's target?
A: The inner Teleport's to is evaluated against the live DOM at mount time. Since the outer Teleport already moved its subtree to a new location, the inner Teleport resolves against that relocated DOM, not the original component tree position.
Examples
Basic: modal that escapes container overflow
<template>
<div class="card" style="overflow: hidden; position: relative;">
<button @click="open = true">Edit</button>
<Teleport to="body">
<div v-if="open" class="overlay" @click.self="open = false">
<div class="dialog">
<h2>Edit item</h2>
<button @click="open = false">Close</button>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
}
.dialog {
background: white;
padding: 24px;
border-radius: 8px;
}
</style>Without Teleport, overflow: hidden on .card clips the modal entirely. Teleport moves the dialog to <body> and it renders correctly. The style is global here, not scoped, so it applies after relocation.
Intermediate: notification toast with SSR-safe pattern
<!-- NotificationToast.vue -->
<template>
<Teleport to="#notifications" :disabled="!mounted">
<Transition name="fade">
<div v-if="visible" class="toast" role="alert">{{ message }}</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({ message: String })
const visible = ref(true)
const mounted = ref(false)
onMounted(() => {
mounted.value = true
setTimeout(() => { visible.value = false }, 3000)
})
</script><!-- index.html -->
<div id="app"></div>
<div id="notifications"></div>Multiple toasts can teleport to the same #notifications container and stack in mount order. The Transition still works because Vue manages animation state on the original component instance, not the relocated DOM. The mounted flag prevents SSR hydration mismatches.
Advanced: reusable modal component with SSR and lifecycle handled
<!-- Modal.vue -->
<template>
<Teleport to="body" :disabled="!isMounted">
<Transition name="fade">
<div v-if="isOpen" class="modal-overlay" @click.self="emit('close')">
<div class="modal-content" role="dialog">
<button class="close-btn" @click="emit('close')">x</button>
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({ isOpen: Boolean })
const emit = defineEmits(['close'])
const isMounted = ref(false)
onMounted(() => { isMounted.value = true })
</script>
<style>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 24px;
border-radius: 8px;
max-width: 500px;
position: relative;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
}
</style>This pattern covers the three things that trip up developers in production. The Teleport escapes parent overflow and z-index. The Transition animates correctly because Vue's animation system operates at the component level. And :disabled="!isMounted" keeps SSR and client rendering in sync. Vuetify 3 uses the same approach for all v-dialog components.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.