Fix: Vue Teleport Not Rendering — Content Not Appearing at Target Element
Part of: React & Frontend Errors
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-rootIn production this is the kind of bug that does not crash the page but quietly breaks user flows. A confirmation modal never appears, so users cannot complete a purchase. A dropdown menu renders behind the page chrome, so the link to “cancel subscription” is unreachable. Conversion drops, but the dashboard stays green because no JavaScript exception was thrown.
Why 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.
A deeper cause: Teleport runs during the component’s mount phase, which executes after the parent template has rendered but before the parent’s onMounted hook. If the target element is created by a sibling component’s onMounted hook, there is a race where Teleport runs first and finds nothing. The console warning appears, the user sees a broken UI, and the developer cannot reproduce it locally because their dev server has slightly different timing.
A second cause unique to Vue 3.2+: when Teleport is used inside a component that gets cached by <KeepAlive>, the teleported content is not automatically restored on re-activation. Modals “remembered” their state but their DOM disappeared, leading to apparent invisibility.
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>Fix 8: Detect Teleport Failures in Production
A silent Teleport failure does not throw an exception, so error monitoring tools never see it. The user sees an invisible modal, abandons the flow, and your conversion funnel drops without any technical alert. Instrument Teleport explicitly:
<script setup>
import { onMounted, nextTick } from 'vue';
const props = defineProps<{ target: string }>();
const emit = defineEmits(['teleport-failed']);
onMounted(async () => {
await nextTick();
const targetEl = document.querySelector(props.target);
if (!targetEl) {
// Report to error tracking with full context
emit('teleport-failed', { target: props.target, route: window.location.pathname });
// Send to Sentry / your APM
if (typeof window !== 'undefined' && (window as any).Sentry) {
(window as any).Sentry.captureMessage(
`Teleport target missing: ${props.target}`,
{ level: 'warning', tags: { component: 'Teleport' } }
);
}
}
});
</script>Monitor click-funnel drop-off. If your analytics shows users clicking the button that opens a modal but never clicking inside the modal, the modal is probably not appearing. Add a custom event:
// When the modal mounts successfully
analytics.track('modal_rendered', { name: 'checkout-confirmation' });
// When the user clicks inside it
analytics.track('modal_interaction', { name: 'checkout-confirmation' });
// If 'modal_rendered' fires 1000 times per day and 'modal_interaction' fires 5
// times, the modal is invisible (or unusable) for most users.The production incident lens. A teleported modal that does not render is the canonical “silent failure” — no exception, no log, no alert. The first signal is usually a support ticket: “I clicked Buy and nothing happened.” By the time it is reported, the bug has been live for hours. The blast radius is “anyone who needs to complete a flow that depends on teleported UI,” which often includes the checkout, login, and account-deletion flows. Recovery is straightforward (fix the target, add SSR guard, ship), but detection is the hard part — which is why instrumentation matters more than the fix itself.
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.
KeepAlive interaction — when a component using Teleport lives inside a <KeepAlive>, the teleported DOM is destroyed when the component is deactivated but the component instance is cached. On re-activation, Teleport remounts and re-creates the target content. If your modal “remembers” being open but the DOM is missing, this is the cause. Move state that should survive deactivation out of the component and use the activated/deactivated lifecycle hooks.
Hydration mismatch in SSR — if the server renders different HTML than the client expects (because Teleport behaves differently on server vs client), Vue warns about a hydration mismatch and may bail out of hydration entirely. Always wrap Teleport in <ClientOnly> or use the :disabled="isServer" pattern to keep server and client output identical for the un-teleported tree.
Iframe target — Teleporting to an element inside an iframe does not work across origins, and is fragile even same-origin because the iframe’s document is a separate DOM. Avoid this pattern. Render the content directly inside the iframe instead.
For related Vue issues, see Fix: Vue Composable Not Reactive, Fix: Vue Pinia State Not Reactive, Fix: Vue Slot Not Working, and Fix: Vue Router 404 on Refresh.
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 Composable Not Reactive — ref and reactive Losing Reactivity After Destructuring
How to fix Vue composable reactivity loss — toRefs for destructuring, returning refs vs raw values, reactive object pitfalls, stale closures, and composable design patterns.