Fix: Vue Teleport Not Rendering — Content Not Appearing at Target Element
Quick Answer
How to fix Vue Teleport not working — target element not found, SSR with Teleport, disabled prop, multiple Teleports to the same target, and timing issues.
The Problem
A Vue <Teleport> component renders nothing or throws an error:
<template>
<Teleport to="#modal-root">
<div class="modal">Modal content</div>
</Teleport>
</template>
<!-- In the browser: nothing appears at #modal-root -->
<!-- Console: [Vue warn]: Invalid Teleport target on mount: null -->Or the teleported content appears in the wrong place:
<Teleport to="body">
<div class="tooltip">Tooltip text</div>
<!-- Expected: appended to <body> -->
<!-- Actual: stays in original DOM position -->
</Teleport>Or Teleport works in development but breaks during SSR (Nuxt, Vite SSR):
[Vue warn]: Teleport target not found: #modal-rootWhy This Happens
<Teleport> moves its content to a different DOM element at render time. The target element must exist in the DOM before the Teleport mounts:
- Target element doesn’t exist yet — if the target (
#modal-root,body, etc.) isn’t in the DOM when the component mounts, Teleport logs a warning and falls back to rendering in place (or renders nothing). - Wrong CSS selector —
to="#modal-root"requires a#prefix for IDs.to="modal-root"(without#) tries to find a<modal-root>HTML element, which doesn’t exist. - SSR mismatch — during server-side rendering, the target element doesn’t exist on the server. Teleport must be disabled during SSR or handled differently.
- Teleport inside a
v-ifthat starts false — the Teleport component mounts whenv-ifbecomes true, but the target must already exist at that moment. - Shadow DOM or isolated components — Teleport can’t cross Shadow DOM boundaries.
Fix 1: Verify the Target Element Exists
The target must be in the HTML before the Vue app mounts:
<!-- index.html — add the portal target BEFORE the app root -->
<!DOCTYPE html>
<html>
<body>
<div id="app"></div> <!-- Vue app root -->
<div id="modal-root"></div> <!-- Teleport target — must exist before mount -->
</body>
</html><!-- Component — target must match exactly -->
<template>
<!-- Correct: ID selector -->
<Teleport to="#modal-root">
<div>Content</div>
</Teleport>
<!-- Also valid: element selector -->
<Teleport to="body">
<div>Content appended to body</div>
</Teleport>
</template>Common selector mistakes:
<!-- WRONG — missing # for ID selector -->
<Teleport to="modal-root"> <!-- Looks for <modal-root> element -->
<!-- WRONG — missing . for class selector -->
<Teleport to="modal-container"> <!-- Looks for <modal-container> element -->
<!-- CORRECT — standard CSS selectors -->
<Teleport to="#modal-root"> <!-- ID -->
<Teleport to=".modal-container"> <!-- Class (uses first match) -->
<Teleport to="body"> <!-- Element type -->Verify the target at runtime:
<script setup>
import { onMounted } from 'vue';
onMounted(() => {
const target = document.querySelector('#modal-root');
console.log('Teleport target:', target);
// null means the element doesn't exist — Teleport will fail
});
</script>Fix 2: Handle Timing Issues
If the target element is created dynamically (e.g., by another component), use disabled with a reactive check:
<script setup>
import { ref, onMounted } from 'vue';
const teleportReady = ref(false);
onMounted(() => {
// Check that target exists before enabling Teleport
teleportReady.value = !!document.querySelector('#modal-root');
});
</script>
<template>
<Teleport to="#modal-root" :disabled="!teleportReady">
<div class="modal">
Modal renders in place until target is ready,
then teleports to #modal-root
</div>
</Teleport>
</template>Create the target element programmatically:
// Create portal target if it doesn't exist (useful in library code)
function ensurePortalTarget(id: string): HTMLElement {
let target = document.getElementById(id);
if (!target) {
target = document.createElement('div');
target.id = id;
document.body.appendChild(target);
}
return target;
}
// In main.ts, before mounting Vue
ensurePortalTarget('modal-root');
ensurePortalTarget('tooltip-root');
const app = createApp(App);
app.mount('#app');Fix 3: Fix SSR (Nuxt / Vite SSR)
During server-side rendering, document doesn’t exist. Teleport must be disabled on the server:
<script setup>
// Nuxt / Vue SSR
const isClient = process.client ?? typeof window !== 'undefined';
</script>
<template>
<!-- Disable Teleport on server, enable on client -->
<Teleport to="body" :disabled="!isClient">
<div class="modal">Modal content</div>
</Teleport>
</template>Nuxt-specific: use <ClientOnly>:
<template>
<!-- Only renders on client — avoids SSR issues entirely -->
<ClientOnly>
<Teleport to="body">
<div class="modal">Modal content</div>
</Teleport>
</ClientOnly>
</template>Nuxt 3 built-in <NuxtTeleport> alternative:
<!-- Nuxt 3 handles SSR teleport correctly -->
<template>
<Teleport to="body">
<div class="modal">
<!-- Vue 3 Teleport works in Nuxt 3 without special handling
as long as the target exists in the layout -->
</div>
</Teleport>
</template>Ensure the target is in the Nuxt layout:
<!-- layouts/default.vue -->
<template>
<div>
<slot />
<!-- Teleport target — available for all pages using this layout -->
<div id="modal-root" />
<div id="toast-root" />
</div>
</template>Fix 4: Use the disabled Prop Correctly
disabled controls whether Teleport moves the content or renders it in place:
<script setup>
const props = defineProps<{
isOpen: boolean;
teleportToBody?: boolean;
}>();
</script>
<template>
<!-- disabled=true: renders in normal DOM position (no teleport) -->
<!-- disabled=false: teleports to target -->
<Teleport to="body" :disabled="!teleportToBody">
<Transition name="modal">
<div v-if="isOpen" class="modal-backdrop">
<div class="modal-content">
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>Using disabled for testing:
// In unit tests, disable Teleport so content renders in the component tree
// (makes it easier to test without needing document.body)
import { mount } from '@vue/test-utils';
import Modal from './Modal.vue';
const wrapper = mount(Modal, {
props: { isOpen: true },
global: {
stubs: {
teleport: true, // Stub Teleport — content renders inline
},
},
});
// Now you can find elements normally
expect(wrapper.find('.modal-content').exists()).toBe(true);Fix 5: Multiple Teleports to the Same Target
Multiple <Teleport> components can target the same element. Content is appended in order:
<!-- Component A -->
<Teleport to="#notifications">
<div class="toast">Notification A</div>
</Teleport>
<!-- Component B (rendered later) -->
<Teleport to="#notifications">
<div class="toast">Notification B</div>
</Teleport>
<!-- Result in #notifications:
<div class="toast">Notification A</div>
<div class="toast">Notification B</div>
Both present, in mount order -->Notification/toast system using multiple Teleports:
<!-- ToastContainer.vue — manages multiple toasts via Teleport -->
<script setup>
import { ref } from 'vue';
const toasts = ref([]);
function addToast(message, type = 'info') {
const id = Date.now();
toasts.value.push({ id, message, type });
setTimeout(() => removeToast(id), 3000);
}
function removeToast(id) {
toasts.value = toasts.value.filter(t => t.id !== id);
}
defineExpose({ addToast });
</script>
<template>
<Teleport to="#toast-root">
<TransitionGroup name="toast" tag="div" class="toast-list">
<div
v-for="toast in toasts"
:key="toast.id"
:class="['toast', `toast--${toast.type}`]"
>
{{ toast.message }}
</div>
</TransitionGroup>
</Teleport>
</template>Fix 6: Teleport with Transitions
Combining <Teleport> with <Transition> requires the transition to be inside the Teleport:
<template>
<Teleport to="body">
<!-- Transition INSIDE Teleport -->
<Transition name="modal" appear>
<div v-if="isOpen" class="modal-backdrop" @click.self="$emit('close')">
<div class="modal-content">
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
<style>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>Fix 7: Accessibility with Teleport
When using Teleport for modals and dialogs, accessibility attributes must still be set:
<script setup>
import { onMounted, onUnmounted } from 'vue';
const props = defineProps<{ isOpen: boolean }>();
const emit = defineEmits(['close']);
// Trap focus and handle Escape key
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
onMounted(() => document.addEventListener('keydown', handleKeyDown));
onUnmounted(() => document.removeEventListener('keydown', handleKeyDown));
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="isOpen"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
class="modal-backdrop"
>
<div class="modal-content">
<h2 id="modal-title"><slot name="title" /></h2>
<slot />
<button @click="$emit('close')" aria-label="Close modal">×</button>
</div>
</div>
</Transition>
</Teleport>
</template>Still Not Working?
Teleport target inside the component — <Teleport> can’t target an element that’s a child of the same component. The target must be outside the component’s DOM subtree (otherwise it’s circular).
Vue 2 vs Vue 3 — <Teleport> is a Vue 3 feature. In Vue 2, the equivalent is vue-portal (a third-party library). If your project uses Vue 2, <Teleport> isn’t available.
z-index and stacking contexts — even after teleporting to body, CSS stacking contexts from ancestor elements can still affect the teleported content. If a modal teleported to body still appears behind other elements, check parent elements for transform, filter, or isolation CSS properties that create new stacking contexts.
For related Vue issues, see Fix: Vue Composable Not Reactive and Fix: Vue Pinia State Not Reactive.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Nuxt Not Working — useFetch Returns Undefined, Server Route 404, or Hydration Mismatch
How to fix Nuxt 3 issues — useFetch vs $fetch, server routes in server/api, composable SSR rules, useAsyncData, hydration errors, and Nitro configuration problems.
Fix: Vue Router Params Not Updating — Component Not Re-rendering or beforeRouteUpdate Not Firing
How to fix Vue Router params not updating when navigating between same-route paths — watch $route, beforeRouteUpdate, onBeforeRouteUpdate, and component reuse behavior explained.
Fix: Vue Slot Not Working — Named Slots Not Rendering or Scoped Slot Data Not Accessible
How to fix Vue 3 slot issues — v-slot syntax, named slots, scoped slots passing data, default slot content, fallback content, and dynamic slot names.
Fix: Vue Composition API Reactivity Lost — Destructured Props or Reactive Object Not Updating
How to fix Vue Composition API reactivity loss — destructuring reactive objects, toRefs, storeToRefs, ref vs reactive, watch vs watchEffect, and template not updating.