モーダルやドロップダウンを実装したとき、z-index が意図どおりに効かず表示が崩れた経験はないでしょうか。原因の多くは CSS のスタッキングコンテキストにあります。Vue3 の組み込みコンポーネント Teleport は、コンポーネントの論理的な親子関係を保ったまま、DOM ツリー上の任意のノードへ要素を転送することでこの問題を根本的に解消します。
Teleport の仕組みと役割
Teleport は Vue 3.0 で追加された組み込みコンポーネントです(出典: Vue.js 公式ドキュメント)。テンプレートの一部を、コンポーネントの DOM 階層の外側にある別の DOM ノードへ「テレポート」できます。
ポイントは 論理的な親子関係に影響しない 点です。Teleport で移動した要素でも、以下の動作はすべて通常どおり維持されます。
- props の受け渡し
$emitによるイベント発行provide/injectによる依存注入- ライフサイクルフックの実行タイミング
つまり、見た目の配置だけを <body> 直下などに移し、ロジック上は元のコンポーネントツリーに留まるという二重構造を実現します。
CSS スタッキングコンテキストと Teleport
position: fixed や position: absolute で要素を配置するとき、親要素に transform、filter、will-change などが設定されていると新しいスタッキングコンテキストが生まれ、z-index が意図どおりに機能しません。
/* 親コンポーネントにtransformがある場合 */
.parent {
transform: translateX(0); /* 新しいスタッキングコンテキストが生成される */
}
.modal {
position: fixed;
z-index: 9999; /* .parent のコンテキスト内でしか効かない */
}
Teleport を使ってモーダル要素を <body> 直下に移動すれば、<body> のスタッキングコンテキストで z-index が評価されるため、この問題を回避できます。
基本構文と Props 一覧
Teleport の構文は <Teleport to="ターゲット"> で囲むだけです。
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
</script>
<template>
<button @click="isOpen = true">開く</button>
<Teleport to="body">
<div v-if="isOpen" class="overlay">
<div class="dialog">
<p>ダイアログの内容</p>
<button @click="isOpen = false">閉じる</button>
</div>
</div>
</Teleport>
</template>
Props リファレンス
| Prop | 型 | 必須 | デフォルト | 説明 |
|---|---|---|---|---|
to | string | HTMLElement | 必須 | — | 転送先の CSS セレクタまたは DOM 要素 |
disabled | boolean | 任意 | false | true にすると転送せず元の位置に描画 |
defer | boolean | 任意 | false | Vue 3.5 以降。アプリ全体のマウント完了後にターゲットを解決 |
to にはCSSセレクタ("#modal-root"、".overlay-container"、"[data-teleport]")のほか、JavaScript で取得した DOM 要素を直接渡すこともできます。
<script setup>
import { ref } from 'vue'
const targetEl = ref<HTMLElement | null>(null)
</script>
<template>
<div ref="targetEl"></div>
<Teleport :to="targetEl" v-if="targetEl">
<p>動的に指定したターゲットへ転送</p>
</Teleport>
</template>
disabled — レスポンシブ対応での活用
disabled prop を動的に切り替えると、画面幅に応じて Teleport の有効・無効を制御できます。たとえばデスクトップではオーバーレイ表示、モバイルではインライン表示に切り替える設計が可能です。
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const isMobile = ref(false)
function checkScreen() {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
checkScreen()
window.addEventListener('resize', checkScreen)
})
onUnmounted(() => {
window.removeEventListener('resize', checkScreen)
})
</script>
<template>
<Teleport to="body" :disabled="isMobile">
<div class="panel">
デスクトップ: body直下 / モバイル: 元の位置
</div>
</Teleport>
</template>
disabled が true から false に変わったとき、既存の DOM ノードはそのままターゲットへ移動します。コンポーネントの再マウントは発生しないため、内部状態やイベントリスナーは保持されます。
defer — Vue 3.5 で追加された遅延解決
Vue 3.5 で導入された defer prop を使うと、テンプレート内で Teleport より後方に定義されたターゲット要素を指定できます(出典: Vue.js 公式ドキュメント)。
通常、<Teleport to="#target"> がマウントされる時点で #target が DOM に存在する必要があります。defer を付与すると、アプリケーション全体のマウントが完了した後(mounted ライフサイクルと同じタイミング)にターゲットを解決します。
<template>
<Teleport defer to="#late-target">
<p>後方で定義されたターゲットへ転送</p>
</Teleport>
<!-- テンプレート内の後方で定義 -->
<div id="late-target"></div>
</template>
defer が有効なのは同一テンプレート内のターゲットに限りません。別コンポーネントがレンダリングするターゲットでも、アプリ全体のマウント完了後であれば解決されます。
同一ターゲットへの複数 Teleport
複数の <Teleport> が同じターゲットを指定した場合、テンプレートに記述した順序で追加されます。
<template>
<Teleport to="#notifications">
<div class="toast toast-info">情報メッセージ</div>
</Teleport>
<Teleport to="#notifications">
<div class="toast toast-warning">警告メッセージ</div>
</Teleport>
</template>
レンダリング結果:
<div id="notifications">
<div class="toast toast-info">情報メッセージ</div>
<div class="toast toast-warning">警告メッセージ</div>
</div>
この特性を活かすと、アプリの複数箇所から通知やトーストメッセージを1か所に集約できます。
実装パターン集
パターン1: アクセシブルなモーダルダイアログ
WAI-ARIA のダイアログパターンに準拠したモーダルの実装例です。role="dialog" と aria-modal="true" を付与し、フォーカストラップも実装します。
<script setup>
import { ref, watch, nextTick } from 'vue'
const isOpen = ref(false)
const dialogRef = ref<HTMLElement | null>(null)
watch(isOpen, async (val) => {
if (val) {
await nextTick()
dialogRef.value?.focus()
}
})
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
isOpen.value = false
}
}
</script>
<template>
<button @click="isOpen = true">モーダルを開く</button>
<Teleport to="body">
<div
v-if="isOpen"
class="modal-backdrop"
@click.self="isOpen = false"
@keydown="onKeydown"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
class="modal-body"
>
<h2 id="modal-title">確認</h2>
<p>この操作を実行しますか?</p>
<div class="modal-actions">
<button @click="isOpen = false">キャンセル</button>
<button @click="isOpen = false">実行</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-body {
background: #fff;
border-radius: 8px;
padding: 24px;
max-width: 480px;
width: 90%;
outline: none;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
</style>
パターン2: トースト通知システム
複数コンポーネントから共通の通知エリアへメッセージを送る仕組みです。Composable と Teleport を組み合わせます。
<!-- composables/useToast.ts -->
<script lang="ts">
import { ref } from 'vue'
interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
}
const toasts = ref<Toast[]>([])
let nextId = 0
export function useToast() {
function show(message: string, type: Toast['type'] = 'info', duration = 3000) {
const id = nextId++
toasts.value.push({ id, message, type })
setTimeout(() => remove(id), duration)
}
function remove(id: number) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
return { toasts, show, remove }
}
</script>
<!-- components/ToastContainer.vue -->
<script setup>
import { useToast } from '@/composables/useToast'
const { toasts, remove } = useToast()
</script>
<template>
<Teleport to="body">
<div class="toast-container" aria-live="polite">
<TransitionGroup name="slide">
<div
v-for="toast in toasts"
:key="toast.id"
:class="['toast-item', `toast-${toast.type}`]"
@click="remove(toast.id)"
>
{{ toast.message }}
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-container {
position: fixed;
top: 16px;
right: 16px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast-item {
padding: 12px 20px;
border-radius: 6px;
color: #fff;
cursor: pointer;
min-width: 200px;
}
.toast-success { background: #22c55e; }
.toast-error { background: #ef4444; }
.toast-info { background: #3b82f6; }
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from { transform: translateX(100%); opacity: 0; }
.slide-leave-to { transform: translateX(100%); opacity: 0; }
</style>
任意のコンポーネントから useToast().show('保存しました', 'success') と呼び出すだけで通知が表示されます。
パターン3: コンテキストメニュー
右クリックで表示するコンテキストメニューも Teleport の典型的なユースケースです。
<script setup>
import { ref } from 'vue'
const isVisible = ref(false)
const position = ref({ x: 0, y: 0 })
function onContextMenu(e: MouseEvent) {
e.preventDefault()
position.value = { x: e.clientX, y: e.clientY }
isVisible.value = true
}
function close() {
isVisible.value = false
}
</script>
<template>
<div @contextmenu="onContextMenu" class="target-area">
右クリックでメニューを表示
</div>
<Teleport to="body">
<div
v-if="isVisible"
class="context-overlay"
@click="close"
>
<ul
class="context-menu"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
>
<li @click="close">コピー</li>
<li @click="close">貼り付け</li>
<li @click="close">削除</li>
</ul>
</div>
</Teleport>
</template>
Transition との併用で注意すべき点
Teleport 内で <Transition> を使う場合、記述する順序に注意が必要です。<Transition> は Teleport の内側 に配置しなければアニメーションが動作しません。
<!-- 正しい書き方 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="isOpen" class="modal">モーダル内容</div>
</Transition>
</Teleport>
<!-- 動作しない書き方 -->
<Transition name="fade">
<Teleport to="body">
<div v-if="isOpen" class="modal">モーダル内容</div>
</Teleport>
</Transition>
加えて、Teleport の転送先で <Transition> を使うには、子コンポーネント側で <Transition> を含める構成にします。親コンポーネントの <Transition> で Teleport 先の要素をラップしても、DOM ツリーが異なるためアニメーションは適用されません。
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
Nuxt 3 での Teleport
Nuxt 3 の SSR 環境では、Teleport の利用に追加の考慮事項があります(出典: Nuxt 公式ドキュメント)。
SSR 対応パターン
Nuxt 3 の SSR では #teleports ターゲットのみがサーバーサイドレンダリングに対応しています。
<!-- SSR対応(推奨) -->
<Teleport to="#teleports">
<div v-if="isOpen" class="modal">
SSRでも正常にレンダリングされます
</div>
</Teleport>
クライアント専用パターン
#teleports 以外のターゲット(body など)を使いたい場合は、<ClientOnly> で囲みます。
<ClientOnly>
<Teleport to="body">
<div v-if="isOpen" class="modal">
クライアントサイドのみでレンダリング
</div>
</Teleport>
</ClientOnly>
Nuxt と Teleport の使い分け
| シナリオ | 推奨パターン |
|---|---|
| SEO 不要の UI 要素(モーダル等) | <ClientOnly> + <Teleport to="body"> |
| SSR で初期表示が必要 | <Teleport to="#teleports"> |
| レイアウト共通の通知エリア | app.vue に <div id="notifications"> を配置し Teleport |
Vue2 からの移行 — PortalVue との違い
Vue 2 には Teleport に相当する組み込み機能がなく、サードパーティライブラリの PortalVue が広く利用されていました。Vue 3 への移行時に押さえておくべき相違点を整理します。
| 項目 | Vue3 Teleport | PortalVue (v2) |
|---|---|---|
| 提供形態 | Vue コア組み込み | サードパーティライブラリ |
| 追加インストール | 不要 | npm install portal-vue |
| ターゲット指定 | CSS セレクタ / DOM 要素 | 名前付き <portal-target> |
| アプリ間の転送 | 不可(同一アプリ内のみ) | 可能 |
| コンポーネント間転送 | DOM ノード指定で可能 | <portal-target> 名で可能 |
| 条件付き無効化 | disabled prop | disabled prop |
| 遅延ターゲット解決 | defer prop (3.5+) | 非対応 |
| SSR | コンテキストオブジェクト経由 | 非対応 |
| TypeScript | 完全対応 | 型定義あり |
PortalVue はアプリケーション内のコンポーネント間でコンテンツを移動する用途(名前付きスロットのような使い方)に強みがあります。一方 Teleport は、モーダルやオーバーレイのように DOM ツリーの外側にレンダリングする用途に特化しています。
React createPortal との比較
React にも同様の機能として createPortal があります。Vue の Teleport は宣言的にテンプレートで記述できる点で、JSX ベースの createPortal とはアプローチが異なります。
| 項目 | Vue3 Teleport | React createPortal |
|---|---|---|
| 記述方法 | テンプレート(宣言的) | JSX / 関数呼び出し |
| 条件付き無効化 | disabled prop | 条件分岐で自前実装 |
| 遅延ターゲット | defer prop | useEffect 内で対応 |
| イベント伝播 | Vue のイベントシステムに従う | React のイベントシステムに従う(Portal 内でもバブリング) |
| 複数ターゲット | 複数の <Teleport> を並べる | 複数の createPortal を返す |
React の createPortal は関数呼び出しのため柔軟性が高い反面、宣言的なテンプレートの可読性という点では Vue の Teleport に優位性があります。
テスト方法
Teleport を含むコンポーネントのテストでは、@vue/test-utils の getComponent や findComponent を使用します。テスト環境では Teleport 先の DOM が存在しないことが多いため、スタブ化やグローバルスタブの設定が有効です(出典: Vue Test Utils 公式ドキュメント)。
import { mount } from '@vue/test-utils'
import MyModal from '@/components/MyModal.vue'
test('モーダルが表示される', async () => {
const wrapper = mount(MyModal, {
global: {
stubs: {
teleport: true // Teleportをスタブ化
}
}
})
await wrapper.find('button').trigger('click')
// スタブ化されたTeleport内の要素を検索
expect(wrapper.html()).toContain('モーダルの内容')
})
teleport: true のスタブ化により、Teleport の内容は元のコンポーネントの DOM 内に描画されます。これにより、ターゲット要素の有無に関係なくテストが実行できます。
実際の DOM への転送もテストしたい場合は、attachTo オプションでドキュメントにマウントし、ターゲット要素を事前に用意します。
test('bodyへの転送を検証', () => {
const target = document.createElement('div')
target.id = 'modal-root'
document.body.appendChild(target)
const wrapper = mount(MyModal, {
attachTo: document.body
})
// Teleport先の要素を直接検索
expect(document.querySelector('#modal-root .modal')).toBeTruthy()
wrapper.unmount()
target.remove()
})
トラブルシューティング
ターゲットが見つからない(“Failed to locate Teleport target”)
Teleport がマウントされる時点でターゲット要素が DOM に存在しない場合にこのエラーが発生します。
対処法:
index.htmlにターゲット要素を静的に配置する
<!-- index.html -->
<body>
<div id="app"></div>
<div id="modal-root"></div> <!-- Teleportのターゲット -->
</body>
- Vue 3.5 以降であれば
deferprop を使う
<Teleport defer to="#dynamic-target">
<!-- ターゲットが後からレンダリングされても対応 -->
</Teleport>
v-ifでターゲットの存在を確認してから描画する
<Teleport v-if="targetExists" to="#dynamic-target">
<!-- targetExistsがtrueになったら転送 -->
</Teleport>
Scoped CSS が適用されない
Teleport で転送された要素は DOM 上では親コンポーネントの外側に配置されますが、Vue の <style scoped> は正しく適用されます。ただし、ターゲット要素自体にスコープ付きスタイルを当てることはできません。
転送先の要素に対してスタイルを適用したい場合は、グローバル CSS またはターゲットコンポーネント側にスタイルを定義してください。
SSR ハイドレーションの不一致
SSR で Teleport を利用すると、サーバーとクライアントの DOM 構造が不一致になりハイドレーションエラーが発生する場合があります。
<body>を Teleport のターゲットにしない(他のサーバーレンダリングコンテンツと混在するため)- 専用のコンテナ要素(
<div id="teleported"></div>)を使用する - Nuxt 3 では
#teleportsターゲットを利用するか<ClientOnly>で囲む
まとめ
Vue3 の Teleport は、z-index やスタッキングコンテキストに起因するレイアウト問題を宣言的に解消できる組み込みコンポーネントです。to でターゲットを指定するだけの手軽さでありながら、disabled によるレスポンシブ制御や defer(Vue 3.5+)による遅延解決など柔軟な機能を備えています。
モーダル・トースト通知・コンテキストメニューなど、DOM 階層を超えた要素配置が求められる場面で活用できます。Nuxt 3 との組み合わせでは #teleports ターゲットや <ClientOnly> の使い分けが重要です。
公式リファレンス: Vue.js Teleport ガイド
