Skip to content

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

FixDevs ·

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

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.

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.

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