iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Teleport

moves rendered content to a different DOM node — typically `body` — while keeping it in the component tree for reactivity, props, and event bubbling. It is the right primitive for modals, popovers, tooltips, and toast roots that must escape parent `overflow: hidden` or `transform` stacking contexts.

Modals, toasts, and conditional teleport

EXAMPLE
<!-- App.vue — single mount point for all teleported content -->
<template>
  <main>
    <router-view />
  </main>

  <!-- These divs receive teleported content. Put them at the BODY level
       so they sit above every stacking context. -->
  <div id='modal-root'></div>
  <div id='toast-root'></div>
</template>

<!-- Modal.vue — moves its body to #modal-root while keeping props/events -->
<script setup lang='ts'>
import { onMounted, onBeforeUnmount } from 'vue';
defineProps<{ open: boolean; title: string }>();
const emit = defineEmits<{ close: [] }>();

// Lock background scroll while open
onMounted(()        => { document.body.style.overflow = 'hidden'; });
onBeforeUnmount(()  => { document.body.style.overflow = ''; });

function onKey(e: KeyboardEvent) { if (e.key === 'Escape') emit('close'); }
</script>

<template>
  <Teleport to='#modal-root'>
    <Transition name='fade'>
      <div v-if='open' class='backdrop' @click.self='emit("close")' @keydown='onKey' tabindex='0'>
        <div role='dialog' aria-modal='true' :aria-labelledby='title' class='dialog'>
          <h2 :id='title'>{{ title }}</h2>
          <slot />
          <button @click='emit("close")'>Close</button>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.4); display: grid; place-items: center; }
.dialog { background: white; padding: 1rem 1.25rem; border-radius: 8px; max-width: 480px; }
.fade-enter-active, .fade-leave-active { transition: opacity .15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

<!-- Toaster.vue — uses #toast-root for a corner overlay -->
<script setup lang='ts'>
import { ref } from 'vue';
type Toast = { id: number; text: string };
const toasts = ref<Toast[]>([]);
let nextId = 0;

function add(text: string, ttl = 3000) {
  const id = nextId++; toasts.value.push({ id, text });
  setTimeout(() => { toasts.value = toasts.value.filter((t) => t.id !== id); }, ttl);
}
defineExpose({ add });
</script>

<template>
  <Teleport to='#toast-root'>
    <ul class='toasts'>
      <li v-for='t in toasts' :key='t.id'>{{ t.text }}</li>
    </ul>
  </Teleport>
</template>

<style scoped>
.toasts { position: fixed; right: 1rem; bottom: 1rem; display: grid; gap: .5rem; list-style: none; padding: 0; }
.toasts li { padding: .5rem .75rem; background: #111; color: white; border-radius: 6px; }
</style>

<!-- 3) Conditional teleport — disable on small viewports for native sheet UI -->
<script setup lang='ts'>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const isLarge = ref(false);
const mq = window.matchMedia('(min-width: 768px)');
const onChange = (e: MediaQueryListEvent) => { isLarge.value = e.matches; };
onMounted(() => { isLarge.value = mq.matches; mq.addEventListener('change', onChange); });
onBeforeUnmount(() => mq.removeEventListener('change', onChange));
</script>
<template>
  <!-- :disabled controls whether teleport actually happens -->
  <Teleport to='#modal-root' :disabled='!isLarge'>
    <div class='dialog'>...</div>
  </Teleport>
</template>

Why it matters

Teleport is the right tool whenever a child component would otherwise be clipped or stacked wrong by an ancestor — `overflow: hidden`, `transform: scale`, or a contained `z-index` context. Drop the dialog into a body-level root and the rest of the page styles cannot fight you.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
<Teleport to="body">
    <Modal v-if="open" />
</Teleport>
Try it Yourself »

Discussion

Loading…