skip to content

In Vue 3, when a child component renders inside `<Teleport to="body">`, which relationships follow the component tree and which follow the DOM?

level: middleimportance: must knowfreq 52%

answer

  1. two trees, two rules
  2. emit and inject ignore DOM position
  3. native events bubble where nodes are
  4. ancestor CSS selectors stop matching

basics

~20 s

Vue relationships follow the component tree: props, emitted events, injections, Devtools nesting and reactivity work as without Teleport. Anything the browser computes from DOM position follows the target: native event bubbling, ancestor-based CSS selectors and contains() checks.

solid answer

~40 s

`<Teleport>` changes only where the nodes are inserted, not the component hierarchy. A `<Dialog>` inside it is still a logical child of the component whose template contains the Teleport, so its props stay reactive, `emit('close')` reaches the parent's `@close` listener, `inject()` resolves against the same ancestors, and Vue Devtools nests it under that parent. What moves is everything the browser derives from DOM ancestry: a native `click` bubbles through `body`, not through the parent's wrapper element; descendant selectors like `.panel .dialog` stop matching; `panelEl.contains(event.target)` returns false. Scoped styles on the dialog's own elements still apply because those elements keep the component's scope attribute.

code

ts · 14 lines
ts
import { onMounted, onUnmounted, type Ref } from 'vue'

export function useClickOutside(
  targets: Ref<HTMLElement | null>[],
  onOutside: () => void,
) {
  const handler = (e: MouseEvent) => {
    const node = e.target as Node
    const inside = targets.some((t) => t.value?.contains(node))
    if (!inside) onOutside()
  }
  onMounted(() => document.addEventListener('click', handler))
  onUnmounted(() => document.removeEventListener('click', handler))
}

go deeper

for a junior

Remember that the component inside Teleport is still the same child of the same parent: props and emitted events keep working.

for a middle

Split the behaviour cleanly: Vue-computed relationships follow the component tree, browser-computed ones follow the DOM. Give native bubbling and descendant selectors as the DOM examples.

for a senior

Show you have debugged the fallout: click-outside checks built on contains(), styles relying on ancestor selectors, and focus order for a dialog appended to body.

for a principal

Argue for one shared overlay primitive that owns containment checks, focus handling and stacking, so feature teams never re-solve the two-tree mismatch on their own.

## Two trees, not one Every Vue app has two hierarchies that usually coincide: - the **component tree**: which component instance created which, maintained by Vue; - the **DOM tree**: which element is inside which, maintained by the browser. `<Teleport>` deliberately makes them diverge. The vnodes inside it are still created by, and belong to, the component whose template contains the Teleport; only the place where Vue inserts the resulting DOM nodes changes. So the rule of thumb is simple: **if Vue computes it, it follows the component tree; if the browser computes it, it follows the DOM.** ## What follows the component tree | Relationship | Behaviour inside `<Teleport to="body">` | |---|---| | Props | Passed and updated reactively exactly as before | | Component events (`emit`) | `emit('close')` still calls the `@close` listener the parent put on the child | | `provide` / `inject` | `inject()` resolves through the same component ancestors | | Lifecycle | The child mounts and unmounts with its owner | | Vue Devtools | The child is nested under the parent, not under `body` | | CSS `v-bind()` variables | Vue marks the teleported nodes and applies the owner's variables to them | The child does not know it has been teleported, and in most cases it does not need to. ## What follows the DOM 1. **Native event bubbling.** Vue attaches native listeners directly to elements; it does not re-route DOM events along the component tree. A `click` inside the teleported dialog bubbles to `body` and `document`, never through a `<div @click>` that wraps the `<Teleport>` in the parent's template. 2. **Click-outside checks.** Code like `panelRef.value.contains(event.target)` returns `false` for clicks inside the dialog, because the dialog is no longer a DOM descendant of the panel. A naive "close when clicking outside the panel" handler therefore closes the dialog on every click inside it. 3. **The CSS cascade.** Inherited properties such as `color` or `font-size` now come from `body`, and descendant selectors like `.settings-panel .dialog` no longer match. 4. **Focus and tab order.** Sequential focus navigation follows DOM order, so a dialog appended to `body` is reached after the rest of the page unless focus is managed explicitly. ## Scoped styles: a nuance A `<style scoped>` block compiles selectors to attribute selectors such as `.dialog[data-v-7ba5bd90]`, and Vue adds that attribute to every element the component's template renders. Teleported elements are still rendered by that template, so they **keep the attribute** and scoped rules aimed at them still match. What stops working is any selector that relies on a **DOM ancestor** that is no longer an ancestor. ## Example ```vue <script setup lang="ts"> import { ref } from 'vue' import ConfirmDialog from './ConfirmDialog.vue' const open = ref(false) function onConfirm() { open.value = false } </script> <template> <div class="settings-panel" @click="console.log('panel click')"> <button @click="open = true">Reset settings</button> <Teleport to="body"> <ConfirmDialog v-if="open" title="Reset?" @confirm="onConfirm" /> </Teleport> </div> </template> ``` - `title` reaches the dialog and `@confirm` fires `onConfirm`: component-tree behaviour. - Clicking inside the dialog never logs `panel click`: DOM behaviour. ## A debugging checklist When something breaks after wrapping content in `<Teleport>`, walk through it in this order: 1. Decide whether the broken behaviour is **Vue-computed** (props, emits, injections, reactivity) or **browser-computed** (events, cascade, focus, layout). Vue-computed behaviour almost never breaks because of Teleport, so look for an unrelated cause first. 2. For events, inspect the real DOM path of the event: log `event.composedPath()` and check whether the element that was expected to see it is actually on that path. 3. For styles, look for selectors that combine an ancestor with the teleported element; move the rule onto the teleported element itself or onto a class the dialog carries. 4. For focus, confirm where focus goes on open and whether it returns to the trigger on close; appending to `body` changes the natural tab sequence. 5. For measurements, re-read positions after the dialog mounts in its new container rather than reusing values from the original position. Working through the two trees explicitly turns vague "Teleport broke my modal" reports into a precise cause. ## Why interviewers ask this The question separates candidates who think Teleport "moves a component" from those who know it moves **DOM nodes only**. Bugs from the second half of the table, such as a click-outside handler closing its own modal or styles vanishing after teleporting, are among the most common Teleport issues in real code, and the fix is always to reason about which tree a feature depends on.

  • Why does a click-outside handler on a panel close its own teleported dialog, and how do you fix it?
    The handler usually checks `panelEl.contains(event.target)`. After teleporting, the dialog's elements live under `body`, so the check fails and every click inside the dialog counts as outside. Fix it by treating the dialog's root element as inside too, passing both refs to the check, or by having the dialog stop handling through its own close events instead of DOM containment.
  • Does `<style scoped>` still style the teleported elements?
    Yes for rules that target those elements directly: the compiler rewrites selectors to include the component's `data-v-` attribute, and teleported elements rendered by the template still carry it. Rules that depend on a DOM ancestor, such as `.panel .dialog`, stop matching because the panel is no longer an ancestor in the DOM.

An employee seated in a satellite office: they still report to the same manager and get the same instructions, but the fire alarm they hear is the satellite building's, not head office's.

saying these in an interview costs you the question

  • A teleported child can no longer emit events to its parent
  • inject() inside a teleported component resolves from body, so it returns undefined
  • Vue re-dispatches native DOM events along the component tree, so wrappers still see them
  • Scoped styles never apply to teleported markup
  • Teleport makes the child a child of the root component in Devtools