Vue 3 Teleport: Render Modals Outside the Component Tree
Vue 3 Teleport moves modal markup to a root DOM node while keeping reactivity intact. Includes SSR target requirements and focus management.
17 Jan 2026, 11:15 UTC

The Clipped Modal Problem
A modal nested inside a sidebar card can disappear behind the header or get clipped by overflow: hidden on a parent. The usual escape hatches - global z-index: 9999, portal libraries, or lifting state to the root - add complexity and weaken component encapsulation.
Vue 3 includes <Teleport>, a built-in way to render a subtree into any DOM element outside the component hierarchy while keeping props, events, and slots fully reactive.
What Teleport Actually Does
<Teleport to="#modals"> tells Vue to mount the wrapped nodes into the element matching that selector. The component instance remains in the normal Vue tree, so v-model, emits, provide/inject, and scoped slots behave exactly as if the markup stayed in place. The target can be a CSS selector or an element reference.
Worked Example: Teleported Modal
This modal renders into a root-level container, bypassing parent clipping and stacking contexts. It also traps focus inside the dialog while open.
<script setup>
import { ref, watchEffect, onUnmounted, nextTick } from 'vue'
const props = defineProps({
modelValue: Boolean,
title: String
})
const emit = defineEmits(['update:modelValue'])
const close = () => emit('update:modelValue', false)
const modalRef = ref(null)
let previousActive = null
watchEffect(() => {
if (props.modelValue) {
previousActive = document.activeElement
document.addEventListener('keydown', trapKeydown)
nextTick(() => {
const focusable = modalRef.value?.querySelector('button, [href], input, select, textarea')
focusable?.focus()
})
} else {
document.removeEventListener('keydown', trapKeydown)
previousActive?.focus()
}
})
function trapKeydown(e) {
if (e.key !== 'Tab') return
const focusable = modalRef.value.querySelectorAll('button, [href], input, select, textarea')
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
</script>
<template>
<Teleport to="#modals">
<div v-if="modelValue" class="overlay" @click.self="close">
<div ref="modalRef" class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<header>
<h2 id="modal-title">{{ title }}</h2>
<button @click="close" aria-label="Close">×</button>
</header>
<main><slot /></main>
</div>
</div>
</Teleport>
</template>
<style scoped>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: #fff; border-radius: 8px; padding: 1.5rem; max-width: 90vw; max-height: 90vh; overflow: auto; }
</style>
Add the target container to your entry HTML before the app mounts:
<div id="modals"></div>
Any component can now use <Modal v-model:modelValue="open" title="Confirm"> without worrying about a clipping ancestor.
SSR Requirement: Target Must Exist in Server HTML
In server-side rendering, the teleport target must already exist in the HTML sent from the server. If #modals is missing, the teleported content renders only after client hydration, producing a visible flash or missing modal on first paint.
Fix: place <div id="modals"></div> in your root layout or entry template so it is part of the server-rendered markup before the hydration root.
Trade-offs to Consider
- CSS ancestor context is lost: Selectors such as
:focus-withinon a parent element will not see focus inside the teleported modal. Manage focus styles on the modal itself. - One target per teleport: A single component instance cannot teleport to multiple targets at once. Use separate instances for multiple modal roots.
- DOM event bubbling: Native events bubble from the teleport target, not from the original component position. Ancestor
@click.outsidedirectives will not fire for teleported content.
Pre-Flight Checklist
- Add
<div id="modals"></div>to your server-rendered HTML. - Wrap modal content in
<Teleport to="#modals">. - Add
aria-modal="true"and a focus trap for keyboard users. - Test with a parent using
overflow: hiddenandz-index; the modal should remain visible. - View page source in an SSR build and confirm the modal markup appears inside
#modalsbefore hydration.
Teleport turns a recurring CSS problem into a single template attribute. Use it for modals, tooltips, and dropdowns that need to escape their container without giving up Vue reactivity.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.