モーダルウィンドウは、ページの上に重ねて表示し、確認や入力を求めるUIです。見た目は簡単に作れますが、実用にするには、表示位置が崩れない工夫、キーボードや読み上げソフトへの対応、そしてスロットの落とし穴を知っておく必要があります。この記事では、Vue 3(<script setup>)で、再利用できるモーダルコンポーネントを作ります。
完成コード:Modal.vue
最初に、完成したコンポーネントを示します。ポイントは次のとおりです。
<Teleport to="body">で、body直下に描画するrole="dialog"、aria-modal="true"、aria-labelledbyで、モーダルであることと見出しを伝える- 開いたらフォーカスをモーダルの中へ移し、閉じたら元の場所へ戻す
- Escキーで閉じ、Tabキーのフォーカスがモーダルの外へ出ないようにする
- 開いている間は、背景のスクロールを止める
- オーバーレイ(背景)のクリックで閉じる
<!-- Modal.vue -->
<script>
// 通常の <script>:全インスタンスで共有されるカウンター(見出しの id を重複させないため)
let uid = 0
</script>
<script setup>
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, required: true }
})
const emit = defineEmits(['close'])
const titleId = `modal-title-${uid++}`
const dialogRef = ref(null)
let lastFocused = null // 開く前にフォーカスがあった要素
function close() {
emit('close')
}
watch(() => props.open, async (isOpen) => {
if (isOpen) {
lastFocused = document.activeElement
document.body.style.overflow = 'hidden' // 背景のスクロールを止める
await nextTick()
dialogRef.value?.focus() // フォーカスをモーダルの中へ移す
} else {
document.body.style.overflow = ''
lastFocused?.focus() // 閉じたら、元の場所へ戻す
}
})
onBeforeUnmount(() => {
document.body.style.overflow = ''
})
// Esc で閉じる/Tab のフォーカスがモーダルの外へ出ないようにする
function onKeydown(event) {
if (event.key === 'Escape') {
close()
return
}
if (event.key !== 'Tab') return
const focusables = dialogRef.value.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
)
if (focusables.length === 0) {
event.preventDefault()
return
}
const first = focusables[0]
const last = focusables[focusables.length - 1]
const active = document.activeElement
if (event.shiftKey && (active === first || active === dialogRef.value)) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && active === last) {
event.preventDefault()
first.focus()
}
}
</script>
<template>
<!-- body 直下に描画する -->
<Teleport to="body">
<div v-if="open" class="modal-overlay" @click.self="close">
<div
ref="dialogRef"
class="modal-content"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
tabindex="-1"
@keydown="onKeydown"
>
<h2 :id="titleId">{{ title }}</h2>
<slot />
<div class="modal-footer">
<!-- footer スロットを渡さなければ、「閉じる」ボタンを出す -->
<slot name="footer">
<button type="button" @click="close">閉じる</button>
</slot>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.modal-content {
min-width: 300px;
max-width: 90vw;
padding: 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.modal-footer {
margin-top: 1.5rem;
text-align: right;
}
</style>
使う側は、次のとおりです。
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'
const isOpen = ref(false)
</script>
<template>
<button type="button" @click="isOpen = true">モーダルを開く</button>
<Modal :open="isOpen" title="お知らせ" @close="isOpen = false">
<p>これはスロットで渡された内容です。</p>
</Modal>
</template>
コードのポイント
Teleportでbody直下に描画する
モーダルが、コンポーネントの深い階層の中に描画されると、表示が崩れることがあります。公式のTeleportのガイドによると、position: fixedは、祖先にtransform、perspective、filterがあると、ビューポートではなくその祖先を基準にしてしまいます。z-indexも、親の要素に制約されます。<Teleport to="body">で囲むと、見た目はbody直下に描画されつつ、論理的には、元のコンポーネントの子のままです。propsやイベントは、これまでどおり動きます。
スロットの中の $emit は動かない
スロットの中身は、モーダルではなく、親のスコープで評価されます。そのため、スロットに渡したボタンに@click="$emit('close')"と書いても、それは親コンポーネントの$emitです。Modalのcloseイベントではないので、何も起きません。親の状態を直接変えてください。
<Modal :open="isOpen" title="お知らせ" @close="isOpen = false">
<p>スロットの中身</p>
<!-- NG:スロットの中身は「親」のスコープ。$emit は親コンポーネントのもので、
Modal の close ではないため、押しても何も起きない -->
<button type="button" @click="$emit('close')">閉じる</button>
<!-- OK:親の状態を、直接変える -->
<button type="button" @click="isOpen = false">閉じる</button>
</Modal>
フォーカスの管理
モーダルを開いたら、フォーカスをモーダルの中へ移します。そうしないと、キーボードの利用者は、背景のページを操作したままになります。閉じるときは、開く前にフォーカスがあった要素に戻します。上のコードでは、Tabキーで、先頭と末尾の要素の間を循環させて、フォーカスが外へ出ないようにしています。
defineEmitsでイベントを宣言する
子が発行するイベント(close)は、defineEmitsで宣言します。宣言がないと、親のリスナーが、ネイティブのイベントとして扱われます。
確認ダイアログとして使う
footerスロットを使うと、ボタンを差し替えられます。タイトルをpropsで渡し、キャンセルと実行を別のボタンにすれば、削除の確認のようなダイアログとして、同じコンポーネントを使い回せます。
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'
const isOpen = ref(false)
function onDelete() {
// 削除の処理...
isOpen.value = false
}
</script>
<template>
<button type="button" @click="isOpen = true">削除</button>
<Modal :open="isOpen" title="削除の確認" @close="isOpen = false">
<p>本当に削除しますか?この操作は元に戻せません。</p>
<!-- footer スロットで、ボタンを差し替える -->
<template #footer>
<button type="button" @click="isOpen = false">キャンセル</button>
<button type="button" @click="onDelete">削除する</button>
</template>
</Modal>
</template>
HTML標準の dialog 要素を使う
ここまで作ってきたことの多くは、HTML標準の<dialog>要素が、ブラウザの機能として提供しています。MDNによると、showModal()で開くと、次の動きが標準で得られます。
- 背景の内容が操作できなくなる(inert)
- Escキーで閉じる
- フォーカスが、モーダルの中に移る
::backdropで、背景を装飾できる- モーダルには、暗黙の
aria-modal="true"が付く
主要なブラウザで、2022年3月から広く使える状態です。Vueで使うときは、refで要素を取り、props.openの変化に合わせてshowModal()とclose()を呼びます。
<!-- DialogModal.vue:HTML 標準の <dialog> を使う版 -->
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, required: true }
})
const emit = defineEmits(['close'])
const dialogRef = ref(null)
// props.open に合わせて、showModal() / close() を呼ぶ
watch(() => props.open, (isOpen) => {
const dialog = dialogRef.value
if (isOpen && !dialog.open) dialog.showModal()
else if (!isOpen && dialog.open) dialog.close()
})
</script>
<template>
<!-- Esc で閉じたときも close イベントが発生するので、親の状態を同期できる -->
<dialog
ref="dialogRef"
:aria-label="title"
@close="emit('close')"
@click.self="emit('close')"
>
<div class="dialog-body">
<h2>{{ title }}</h2>
<slot />
<button type="button" @click="emit('close')">閉じる</button>
</div>
</dialog>
</template>
<style scoped>
/* dialog 自体に余白があると、余白部分のクリックが「外側のクリック」と判定されるため、
余白は内側の div に持たせる */
dialog { padding: 0; border: none; border-radius: 8px; }
.dialog-body { padding: 2rem; }
dialog::backdrop { background: rgba(0, 0, 0, 0.5); }
</style>
外側のクリックで閉じる処理は、MDNによると、属性closedby="any"でも指定できます。ただし、対応状況は、対象のブラウザで確認してください。
自作と標準の使い分け
- dialog要素:フォーカスの管理や、Escキー、背景の無効化をブラウザに任せられる。まずはこちらを検討する。
- 自作のモーダル:トランジションなど、見た目を細かく制御したい場合や、既存のUIライブラリの流儀に合わせたい場合。アクセシビリティは、自分で担保する必要がある。
アニメーションを付ける
開閉にアニメーションを付けるには、<Transition>が使えます。基礎はtransitionコンポーネントを使ってアニメーションを実装する方法、高さの変化を伴う例はアニメーション付きアコーディオンを実装する方法をご覧ください。
関連記事
- 開閉のUIとアクセシビリティの考え方はシンプルなアコーディオンを作成する方法
- モーダルの開閉状態をアプリ全体で持つならPiniaを使った状態管理の始め方
- スロットとpropsの使い分けはコンポーネント間でデータをやりとりする方法
よくある質問(FAQ)
$emit('close')は親のものです。親の状態を、@click="isOpen = false"のように直接変えるか、スロットのprops(スコープ付きスロット)で閉じる関数を受け取ってください。transformやfilter、z-indexの制約があるのが原因です。<Teleport to="body">で、body直下に描画してください。document.body.style.overflow = 'hidden'を設定し、閉じるときと破棄されるときに戻します。dialog要素のshowModal()は、背景を操作不能にします。まとめ
モーダルは、<Teleport>でbody直下に描画し、role="dialog"とフォーカスの管理、Escキーで閉じる動作を備えると、実用的になります。スロットの中身は親のスコープで評価されるため、$emitの落とし穴に注意してください。多くの機能は、HTML標準の<dialog>要素にも備わっているので、まずは標準を検討し、必要に応じて自作を選びましょう。

