Teleport
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.
Discussion
Loading…