Skip to content

Fix: Vue Teleport Not Rendering — Content Not Appearing at Target Element

FixDevs · (Updated: )

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-root

In 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 selectorto="#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-if that starts false — the Teleport component mounts when v-if becomes 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles