Using Vue 3 Teleport to Build Accessible Modals Without Breaking Reactivity
Vue 3’s Teleport lets you render a component’s DOM subtree into a different part of the page while keeping reactivity and component state intact. Follow a concrete example, see trade‑offs, and get a quick checklist to get started.
01 Nov 2025, 19:24 UTC

Problem: Modals and Tooltips Need to Escape the Normal Flow
In a typical single‑page application, modal dialogs, dropdown menus, and tooltips must appear on top of all content. If you render them in the same DOM subtree as the triggering component, they can be clipped by parent containers, inherit unwanted styles, or break stacking contexts. The classic solution is to move the markup to a top‑level div that sits outside the component hierarchy.
Historically this required manual DOM manipulation or a third‑party portal library, which often broke Vue’s reactivity system and made cleanup difficult. Vue 3 introduces Teleport, a built‑in directive that solves this problem natively.
What Teleport Does
- Renders a component’s children into a target element specified by a CSS selector.
- Keeps the component in the Vue component tree, so its reactive data, lifecycle hooks, and scoped styles continue to work.
- Falls back to the original location if the target is not present or is removed.
- Does not alter CSS scoping; global styles may be required for portal content.
Teleport is available only in Vue 3.x. It is not a drop‑in replacement for Vue 2’s portal-vue plugin.
Concrete Example: A Reusable Modal Component
Below is a minimal, self‑contained modal that demonstrates Teleport in action. The modal toggles open/closed, traps focus, and updates its reactive message property.
<!-- index.html – make sure this exists in the root of the page -->
<div id="app"></div>
<div id="modal-root"></div>
/* main.js */
import { createApp, ref } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
/* App.vue */
<template>
<button @click="open = true">Open Modal</button>
<Modal v-if="open" @close="open = false" />
</template>
<script setup>
import { ref } from 'vue';
import Modal from './components/Modal.vue';
const open = ref(false);
</script>
/* components/Modal.vue */
<template>
<Teleport to="#modal-root" v-slot="{ isActive }">
<div class="modal-backdrop" @click.self="close()">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Modal Title</h2>
<p>{{ message }}</p>
<button @click="close()">Close</button>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({});
const emit = defineEmits(['close']);
const message = ref('Hello from the modal!');
function close() {
emit('close');
}
// Simple focus trap – not production‑ready but illustrates the point
let previousActive;
onMounted(() => {
previousActive = document.activeElement;
const modal = document.querySelector('.modal');
modal.focus();
});
onBeforeUnmount(() => {
if (previousActive) previousActive.focus();
});
</script>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: white;
padding: 1rem;
border-radius: 4px;
outline: none;
}
</style>
Key points:
- The
<Teleport>tag renders the modal’s markup inside#modal-root. - Because the component stays in the Vue tree,
messageremains reactive andclose()updates the parent state. - Scoped CSS continues to work; the modal’s styles are applied only to its own subtree.
- The modal can be styled globally if needed by adding a global CSS rule for
.modal-backdropor.modal.
Trade‑offs and Limitations
- Target Element Must Exist Early: Teleport falls back to the original location if the target is missing. Ensure
#modal-rootis present before the component mounts. - CSS Context Loss: Parent‑based selectors (e.g.,
.parent > .child) no longer apply inside the portal. Use global selectors or pass classes explicitly. - Accessibility Responsibility: Teleport does not handle focus trapping, aria attributes, or screen‑reader announcements. The example adds a very basic focus trap; production code should use a library or implement full WAI‑ARIA practices.
- Lifecycle Hooks Remain Intact: While this is usually an advantage, be aware that the component’s
mountedhook runs when the component is created, not when it appears in the portal. If you need to run code after the element renders inside the target, usenextTickorwatchEffectwith a reference to the portal element. - Only Vue 3: Teleport is not available in Vue 2.x or earlier. If you need to support older versions, consider
portal-vueor manual DOM manipulation.
Actionable Checklist for Production Use
- Confirm you are on Vue 3.x:
vue --versionornpm list vue. - Add a unique root element for portals (e.g.,
<div id="modal-root"></div>) in your main HTML file. - Wrap the component’s markup in
<Teleport to="#modal-root">and test that it renders inside the target using dev tools. - Verify reactivity: change a reactive property inside the portal component and confirm the UI updates.
- Check scoped styles: ensure that styles defined with
scopeddo not leak outside the portal, and that global styles apply only where intended. - Implement full accessibility: focus trap,
aria-modal,role="dialog", and keyboard handling. Use a library likefocus-trap-vueif needed. - Test fallback behavior: temporarily remove
#modal-rootand confirm the component reverts to its original location without breaking layout. - Review performance: Teleport does not clone the component; it moves the existing DOM nodes. If you need many portals, monitor memory usage.
- Document the portal usage in your component library so other developers understand the target requirement.
Conclusion
Vue 3’s Teleport gives you a lightweight, declarative way to render components outside their usual DOM location while preserving all the benefits of Vue’s component model. By following the checklist above, you can build robust, accessible modals, tooltips, or any UI that needs to escape the normal flow without sacrificing reactivity or maintainability.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.