Teleport and Suspense
Render a modal outside a clipping parent with Teleport and make it usable from the keyboard, then show a fallback with Suspense while async setup runs and catch a setup that fails.
What you'll learn
When you finish, you can do these.- 1Render a modal over the whole page with Teleport
- 2Move focus into a modal and back when it closes
- 3Show loading text with Suspense and catch a failed load
- 4Fix a Teleport whose target is not on the page yet
Picture a page with a card, a box with a border, and an Open settings button inside it. The button opens a modal: a dialog over a dark backdrop that should cover the whole page. Inside the card, it does not. The card hides whatever sticks out past its border (overflow: hidden) and lifts on hover (transform), so the backdrop stops at its edge and the dialog is cut off.
Vue's <Teleport> keeps the modal in its component but puts its markup, the HTML it renders, at the end of the page's <body>. The Code tab holds the finished page: the card in App.vue, the modal in Modal.vue, and a profile, UserCard.vue, that loads slowly while <Suspense> shows a loading message. You need one idea from Error handling and debugging: onErrorCaptured(), a parent component's hook that receives errors from the components inside it.
Teleport moves the markup, not the component
<Teleport to="body"> is what lets the modal cover the page: it renders its content inside the element that to selects. Only the DOM, the browser's tree of page elements, changes. In the component tree, Modal.vue stays a child of App, so props, events and scoped styles work as usual.
The diagram puts a Page component between App and Modal: however deep Modal sits, its <div> lands under <body>.
The template of Modal.vue puts the backdrop and the dialog inside the Teleport, and emit('close') asks App to close the modal:
<Teleport to="body" :disabled="!teleport">
<div v-if="open" class="backdrop" @click.self="emit('close')">
<div class="dialog" role="dialog" aria-modal="true" aria-label="Settings">
<slot />
<button ref="closeButton" @click="emit('close')">Close</button>
</div>
</div>
</Teleport>
Click Open settings in the preview: the backdrop covers the whole preview.
The v-if sits on the content, not on the Teleport, so the Teleport stays mounted, and a <Transition> inside it, Vue's component for enter and leave animations, can still animate the modal out.
disabled renders the content in place instead, for a menu that should sit inline on a phone and float on a laptop. The "Teleport the modal to body" checkbox switches it.
A modal people can use
A modal must also work from the keyboard. Focus marks the element your keys act on. When the modal opens, focus moves into it; when it closes, focus returns to the button that opened it, so a keyboard user is not dropped at the top of the page.
In Modal.vue, a watcher on the open prop does both. opener keeps document.activeElement, the element that has focus, and closeButton is a template ref to the Close button:
watch(() => props.open, (isOpen) => {
if (isOpen) {
opener = document.activeElement as HTMLElement | null
closeButton.value?.focus()
} else {
opener?.focus()
}
}, { flush: 'post' })
In the preview, press Tab until Open settings shows a focus ring, then press Enter: the ring lands on Close. Press Enter again, and the ring is back on Open settings.
flush: 'post' runs the callback after Vue has updated the page, so the Close button exists when it gets focus. Try it 2 adds closing on Esc.
For a plain modal, the browser's own <dialog> is an alternative: showModal() draws it above everything, where no ancestor clips it, and handles focus and Esc.
Suspense waits for async setup
Suspense handles a different problem: waiting for data. A profile card, UserCard.vue in the Code tab, cannot render until the user's details arrive from a server.
A top-level await in its <script setup> makes its setup async: Vue cannot render it until the promise, JavaScript's placeholder for a value that comes later, settles. Here a timer resolves it after 1.5 seconds, in place of a server:
<script setup lang="ts">
const user = await new Promise<{ name: string, city: string }>((resolve) => {
setTimeout(() => resolve({ name: 'Asha', city: 'Lisbon' }), 1500)
})
</script>
Until every async setup inside it has finished, <Suspense> shows its #fallback slot. In App.vue, the Load user button sets showUser to true, which renders this Suspense (its v-else-if pairs with an error message, below):
<Suspense v-else-if="showUser">
<UserCard />
<template #fallback><p>Loading user…</p></template>
</Suspense>
Click Load user: Loading user… shows, then the card reads Asha lives in Lisbon.
Suspense waits at any depth inside it, and for components loaded with defineAsyncComponent(), whose code downloads only when needed. Their own loadingComponent, delay and timeout are then ignored unless you pass suspensible: false. Vue's documentation still labels Suspense experimental.
The promise can also reject, that is, fail: Try it 3 makes it fail with the error User 42 not found. A rejected setup is an error, not a loading state, and Suspense has no slot for it. The error travels up the component tree to App, which catches it with onErrorCaptured(), stores the message in loadError and returns false so it goes no further:
onErrorCaptured((err) => {
loadError.value = (err as Error).message
return false
})
A paragraph with v-if="loadError", right before the Suspense and its v-else-if, then reads Could not load the user: User 42 not found.
Without that hook, the visitor sees no message. The card renders nothing once app.config.errorHandler, the app's last stop for errors, receives the error; in development with no handler, the fallback stays for good.
What goes wrong
The first mistake leaves out the Teleport, so the modal's markup stays inside the card.
<div v-if="open" class="backdrop" @click.self="emit('close')">
<div class="dialog" role="dialog" aria-modal="true" aria-label="Settings">…</div>
</div>
The backdrop covers only the card, and the dialog is cut off at its border. The backdrop's position: fixed normally places it against the window, but an ancestor with a transform becomes the box it is placed in, and that ancestor's overflow: hidden clips it. No z-index gets it out.
<Teleport to="body">
<div v-if="open" class="backdrop" @click.self="emit('close')">…</div>
</Teleport>
Inside the Teleport, the backdrop covers the whole page.
The second mistake needs a target that your own template renders, since <body> always exists. Picture a Saved message sent to a sidebar, <aside id="sidebar">, that comes later in the same template.
<Teleport to="#sidebar">
<p>Saved</p>
</Teleport>
<aside id="sidebar"></aside>
Nothing appears in the sidebar, and the console warns Failed to locate Teleport target with selector "#sidebar". A Teleport looks for its target as it mounts, and the aside does not exist yet.
<Teleport defer to="#sidebar">
<p>Saved</p>
</Teleport>
<aside id="sidebar"></aside>
defer waits until the rest of the render is done before it looks, so Saved lands in the sidebar.
What to remember
<Teleport to>changes where the markup lands in the DOM. The component stays where it is in the tree, with its props, events and scoped styles.- Put
v-ifon the content inside the Teleport, and usedisabledto render in place. - A usable modal moves focus in, returns it on close and closes on Esc. A native
<dialog>withshowModal()does most of that for you. <Suspense>shows#fallbackuntil every async setup inside it is ready. A rejected setup is an error: catch it withonErrorCaptured()in the parent.- The target must exist when the Teleport mounts. For a target rendered later in the same template, add
defer.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Untick "Teleport the modal to body" and click Open settings: the modal is cut off at the card's border. Click the dark area to close it, tick the box and reopen it: the backdrop covers the whole preview. Then delete
transform: translateY(0);and the.card:hoverrule inApp.vueand untick again: the modal covers the page without Teleport, becauseoverflow: hiddenalone does not clip a fixed element. - Close on Esc. On the
.dialogdiv inModal.vue, add@keydown.esc="emit('close')". Open the modal and press Esc: it closes and the focus ring is back on Open settings. Focus starts on Close, inside the dialog, so the key event reaches the div. - In
UserCard.vue, make the promise fail:(resolve, reject) => { setTimeout(() => reject(new Error('User 42 not found')), 1500) }. Click Load user: Loading user… shows for 1.5 seconds, then Could not load the user: User 42 not found. Delete theonErrorCapturedblock inApp.vueand click Load user again: the loading text disappears, nothing takes its place, and the preview shows the error, logged by the preview'sapp.config.errorHandler.