Vue モーダルの作り方・表示方法を徹底解説
ユーザーインターフェースにおいて、特定の情報を強調したり、ユーザーに操作を促したりするためにモーダルウィンドウは非常に役立ちます。Vue.jsを使用することで、リアクティブで再利用可能なモーダルコンポーネントを効率的に作成できます。
この記事では、Vue.jsにおけるモーダルの基本的な実装方法から、再利用可能なコンポーネント化、状態管理、アニメーション、アクセシビリティへの配慮、そしてVue 3で導入されたTeleport
機能を使ったモダンな実装まで、詳細かつ網羅的に解説します。約5000語にわたる解説を通して、Vueでモーダルを完全にマスターすることを目指します。
はじめに:モーダルとは何か、なぜVueでモーダルを作るのか
モーダルウィンドウ(Modal Window)とは、親ウィンドウ(通常はブラウザの表示領域)の上に重ねて表示され、ユーザーがそのモーダル以外の操作を行えないようにするウィンドウのことです。モーダルが表示されている間は、背景の親ウィンドウは操作不能(またはフォーカスできない状態)になります。
モーダルは以下のような様々な用途で使用されます。
- 情報の表示: 重要な通知、エラーメッセージ、詳細情報の表示など。
- ユーザーへの操作要求: 確認ダイアログ(「保存しますか?」「削除しますか?」)、同意確認など。
- データ入力: ログインフォーム、新規作成フォーム、設定画面など。
なぜVue.jsでモーダルを作るのが適しているのでしょうか?
- コンポーネント指向: モーダルは独立したUI要素として捉えやすく、コンポーネントとして定義することで、再利用性や保守性が向上します。
- リアクティビティ: モーダルの表示/非表示はアプリケーションの状態(データ)に基づいて制御できます。Vueのリアクティブシステムを使えば、データの変更に応じてモーダルの表示が自動的に更新されます。
- トランジションシステム: Vueは要素の表示/非表示にアニメーションを簡単に適用できるトランジションシステムを提供しており、モーダルの開閉にスムーズなアニメーションを加えることができます。
- 柔軟なDOM操作: Vue 3の
Teleport
のような機能は、モーダルのような要素をDOMツリー内の特定の場所にレンダリングすることを容易にし、スタイリングやスタッキングコンテキストの問題を解決します。
それでは、最も基本的なモーダルの実装方法から始めて、段階的に洗練された方法へと進んでいきましょう。
セクション 1:最も基本的なモーダル実装(v-if
を使用)
まずは、特定のボタンをクリックしたらモーダルが表示され、モーダル内の閉じるボタンをクリックしたら非表示になる、という最もシンプルな仕組みから解説します。ここでは、Vueのディレクティブであるv-if
を使用します。
v-if
は、指定された条件が真(true)の場合に要素をレンダリングし、偽(false)の場合は要素をDOMから完全に削除します。
実装のステップ
- モーダルの表示状態を管理するデータプロパティを用意します。
- モーダルの構造(HTML)を作成します。
- モーダル要素に
v-if
ディレクティブを適用し、データプロパティと紐付けます。
- モーダルを表示/非表示にするボタンや要素に、表示状態を切り替えるメソッドを紐付けます。
- モーダルのスタイリング(CSS)を行います。
コード例
“`vue
Vue 基本モーダル (`v-if`)
基本モーダル
これはv-ifで制御されるシンプルなモーダルです。
“`
解説
data()
オプションで showModal: false
という状態を定義しています。これがモーダルの表示/非表示を制御します。
- ボタンの
@click="showModal = true"
で、クリック時に showModal
を true
にしてモーダルを表示します。
- モーダルのルート要素(
.modal-overlay
)に v-if="showModal"
を設定しています。これにより、showModal
が true
の時だけこの要素とその子要素がDOMに存在し、false
の時はDOMから削除されます。
- モーダル内の閉じるボタンの
@click="showModal = false"
で、クリック時に showModal
を false
にしてモーダルを非表示にします。
- CSSで
.modal-overlay
を position: fixed
にして画面全体を覆い、background-color: rgba(0, 0, 0, 0.5)
で半透明の背景を作っています。display: flex
, justify-content: center
, align-items: center
を使って、子要素の .modal-content
を画面中央に配置しています。z-index: 1000
はモーダルが他の要素の上に確実に表示されるようにするためです。
v-if
のメリットとデメリット
-
メリット:
- 実装が非常にシンプルで分かりやすい。
- モーダルが非表示の時はDOMに存在しないため、パフォーマンスの点で有利になる場合がある(特にモーダル内のコンテンツが重い場合)。
- 初期表示時に非表示の場合、その分のHTML構造はブラウザによってパースされない。
-
デメリット:
- モーダルを表示するたびにDOMが生成され、非表示にするたびにDOMから削除されるため、頻繁に表示/非表示を繰り返す場合にコストがかかる可能性がある。
- 要素の表示/非表示に伴うアニメーション(トランジション)を直接適用するのが、後述する
v-show
に比べて少しだけ手間がかかる(<transition>
コンポーネントを使用する必要がある)。
- 最も重要なデメリット: この実装では、モーダルのロジックとUIがモーダルを表示する親コンポーネント内に直接記述されています。もし複数の場所で同じようなモーダルを使いたい場合、コードの重複が発生し、再利用性がありません。
この最も基本的な実装は、本当に一度しか使わないようなシンプルなモーダルや、学習の最初のステップとしては有効ですが、実際のアプリケーション開発では再利用可能なコンポーネントとして実装することが一般的です。
セクション 2:v-show
を使った基本的なモーダル実装
v-if
の代わりにv-show
を使ってモーダルを実装することも可能です。v-show
は、条件が真の場合は要素を表示し、偽の場合は要素を display: none;
スタイルを適用して非表示にします。要素自体はDOMから削除されません。
コード例 (v-show
版)
v-if
をv-show
に置き換えるだけで、Vueテンプレート側の変更は最小限です。
“`vue
Vue 基本モーダル (`v-show`)
基本モーダル
これはv-showで制御されるシンプルなモーダルです。
“`
v-show
のメリットとデメリット
-
メリット:
- 初期レンダリング時に要素がDOMに存在するため、表示/非表示の切り替えが
v-if
よりも高速になる場合がある(DOM操作ではなくCSSスタイルの変更のみ)。
- 表示/非表示を頻繁に繰り返すUI要素に向いている。
- CSSトランジションやアニメーションとの組み合わせが
v-if
より若干容易な場合がある(v-show
で制御される要素に直接CSSトランジションを適用しやすい)。
-
デメリット:
- 要素が非表示の場合でもDOMに存在するため、初期レンダリングコストは
v-if
より高くなる可能性がある(特にモーダル内のコンテンツが重い場合)。
- 非表示要素もDOMツリーの一部であるため、DOM構造が複雑になる可能性がある。
v-if
と同様に、このままでは再利用性がない。
v-if
とv-show
の使い分け
一般的に、
v-if
: 条件がほとんど変化しない場合、または要素の表示/非表示によってDOMツリー構造を大きく変更したい場合に適しています。初期描画の最適化にも有利です。
v-show
: 条件が頻繁に変化する場合、要素の表示/非表示の切り替えパフォーマンスが重要な場合に適しています。要素は常にレンダリングされているため、初回表示はv-if
より速い可能性がありますが、非表示時のメモリ使用量はv-if
より大きくなります。
モーダルの場合、アプリケーションの起動時に表示されることは少なく、ユーザーのアクションによってのみ表示されるのが一般的です。また、一度表示されたら頻繁に閉じたり開いたりするよりも、ある程度の時間表示されたままになることが多いでしょう。しかし、モーダル内のコンテンツの重さや、開閉時のアニメーションの要件によってどちらが適しているかは変わってきます。
どちらの方法を使っても、この段階の実装は再利用性という点で大きな課題を抱えています。次に、この課題を解決するために、モーダルを独立したコンポーネントとして作成する方法を学びます。
セクション 3:再利用可能なモーダルコンポーネントの作成
アプリケーションで複数のモーダルを使用する場合、あるいは異なる場所で同じタイプのモーダルを表示したい場合は、モーダルを独立したVueコンポーネントとして作成するのが最も効果的です。これにより、コードの重複を避け、保守性を高めることができます。
再利用可能なモーダルコンポーネントは、以下の要素を持つべきです。
- Props: 親コンポーネントからモーダルの表示状態、タイトル、ボタンのテキストなどの設定を受け取ります。
- Events: モーダルが閉じられた、確認ボタンがクリックされたなどのイベントを親コンポーネントに通知します。
- Slots: モーダルのメインコンテンツ(本文、フォームなど)はモーダルごとに異なるため、Slotsを使って柔軟に親コンポーネントからコンテンツを挿入できるようにします。
- 内部状態とロジック: モーダルの表示/非表示自体はPropsで受け取りますが、内部的な表示制御(例:閉じるボタンが押されたらイベントを発行)はコンポーネント自身が行います。
- スタイリング: モーダルの基本的な見た目(オーバーレイ、中央寄せ、境界線など)をコンポーネント内で定義します。
ここでは、Vue 3の <script setup>
構文を使ってコンポーネントを作成します。
Modal.vue コンポーネントの作成
components
フォルダなどに Modal.vue
という名前でファイルを作成します。
“`vue
“`
解説
-
Props (defineProps
):
show
: Boolean
型で必須。親コンポーネントがモーダルの表示状態(true
/false
)をこのPropで渡します。
title
: String
型で任意。モーダルのタイトルとして表示されます。header
スロットが優先されます。
closable
: Boolean
型で任意(デフォルトtrue
)。閉じるボタンやオーバーレイクリック、Escキーでモーダルを閉じられるかを制御します。
closeOnClickOutside
: Boolean
型で任意(デフォルトtrue
)。オーバーレイクリックで閉じられるかを制御します。closable
がtrue
の場合のみ有効です。
closeOnEsc
: Boolean
型で任意(デフォルトtrue
)。Escキーで閉じられるかを制御します。closable
がtrue
の場合のみ有効です。
-
Events (defineEmits
):
close
: モーダルが閉じられるべき時に親コンポーネントに通知するためのイベントを定義しています。モーダルコンポーネント自体はモーダルの状態(show
Prop)を変更することはしません。状態管理は親コンポーネントの責任であり、子はイベントを通じて変更を要求するだけです(「開いて/閉じて」ではなく、「閉じたいです」と伝えるイメージ)。
-
Slots:
<slot name="header"></slot>
: モーダルのヘッダー部分です。親コンポーネントから <template #header>...</template>
を使ってカスタムヘッダーコンテンツを挿入できます。もしheader
スロットが提供されず、title
Propが設定されていれば、デフォルトのタイトルが表示されます。
<slot></slot>
(default slot): モーダルのメインコンテンツ部分です。親コンポーネントはモーダルタグの間に直接コンテンツを記述することで、ここに表示させることができます。
<slot name="footer"></slot>
: モーダルのフッター部分です。主にアクションボタン(「OK」「キャンセル」など)を配置するために使われます。親コンポーネントから <template #footer>...</template>
を使ってカスタムフッターコンテンツを挿入できます。
-
内部ロジック (<script setup>
):
closeModal()
: emit('close')
を呼び出し、親コンポーネントにモーダルを閉じるよう要求します。
handleOverlayClick()
: オーバーレイ(.modal-overlay
)をクリックしたときに呼ばれます。.self
修飾子が付いているため、モーダル内の要素をクリックしてもこのイベントは発生しません。closable
とcloseOnClickOutside
がtrue
であればcloseModal
を呼び出します。
handleKeydown()
: Escキーが押されたときに呼ばれます。show
がtrue
、closable
とcloseOnEsc
がtrue
であればcloseModal
を呼び出します。
watch(() => props.show, ...)
: show
Propの値の変化を監視します。モーダルが開いた(newValue === true
)時にdocument
にkeydown
イベントリスナーを追加し、閉じた時に削除します。これにより、Escキーでのクローズを実現しています。また、簡単なアクセシビリティ対応として、モーダル表示中にbody
のスクロールを無効化/有効化しています。
onUnmounted
: コンポーネントがDOMから削除される際に、追加したイベントリスナーとbody
のスタイル変更を確実に元に戻すために使用します。
-
スタイリング (<style scoped>
):
- 前述の基本的なCSSに加えて、ヘッダー、ボディ、フッターの構造に対応するスタイルを追加しています。
.modal-container
に position: relative;
を追加したのは、もし閉じるボタンを絶対配置にする場合の基準とするためです(ここでは閉じるボタンはヘッダー内に配置しています)。
max-height: 90vh;
と overflow-y: auto;
を .modal-container
に適用することで、モーダル内のコンテンツが画面の高さを超えた場合にモーダル自体がスクロールするようにしています。
親コンポーネントでの使用方法
作成した Modal.vue
コンポーネントを親コンポーネント(例: App.vue
)で使用します。
“`vue
再利用可能なモーダルコンポーネント
これは、親コンポーネントから渡されたデフォルトコンテンツです。
様々なHTML要素を含めることができます。
カスタム モーダルヘッダー
ここでは、モーダルの本文を自由に記述できます。
フォーム要素やリストなども配置可能です。
この操作を実行してもよろしいですか?
“`
解説
- 親コンポーネントは、インポートした
Modal
コンポーネントを <Modal>
タグとして使用します。
:show="showBasicModal"
のように、親コンポーネントのデータプロパティを show
Propにバインドします。これにより、親のデータが変更されると子のPropも更新されます。
@close="showBasicModal = false"
のように、子コンポーネント(Modal.vue)が発行する close
イベントをリッスンし、そのイベントが発生したら親のデータプロパティを false
に変更してモーダルを非表示にします。これが「Props down, Events up」というVueの一般的なデータフローパターンです。
- Slotsを使ってモーダル内のコンテンツを渡しています。
<Modal>...</Modal>
の間のコンテンツはデフォルトスロットに入ります。
<template #header>...</template>
で囲まれたコンテンツは header
という名前のスロットに入ります。
<template #footer>...</template>
で囲まれたコンテンツは footer
という名前のスロットに入ります。
handleCustomAction
やhandleConfirmAction
のようなメソッドは、モーダル内のアクションボタン(footer
スロットに配置)がクリックされたときに実行され、必要に応じてモーダルを閉じる処理(親の状態を変更)を含みます。
再利用可能なコンポーネント化のメリット
- DRY (Don’t Repeat Yourself): モーダルの基本的な構造、表示/非表示ロジック、CSSが
Modal.vue
一箇所に集約されます。
- 保守性: モーダルのデザインや基本的な動作を変更したい場合、
Modal.vue
ファイルだけを修正すれば、アプリケーション全体で使用されている全てのモーダルに反映されます。
- 可読性: 親コンポーネントのテンプレートは、
<Modal>...</Modal>
のようにシンプルになり、モーダルの目的(例:「基本モーダル」「確認モーダル」)と表示されるコンテンツに集中できます。
- テスト容易性:
Modal.vue
コンポーネントを単体でテストしやすくなります。
この方法が、Vueでモーダルを作成する上で最も一般的で推奨されるアプローチです。しかし、まだ改善の余地があります。特に、モーダルが表示されるDOM上の位置、トランジション、そしてより高度なアクセシビリティ対応です。
セクション 4:アニメーション(トランジション)の追加
モーダルの表示・非表示にアニメーションを加えることで、ユーザー体験を向上させることができます。Vueには <transition>
コンポーネントがあり、要素の表示・非表示、リストの移動などにアニメーションを簡単に適用できます。
<transition>
コンポーネントの使い方
<transition>
コンポーネントで囲まれた要素は、以下のタイミングで特定のCSSクラスが自動的に付与/削除されます。
v-enter-from
: 要素が挿入される直前の状態(アニメーション開始時のスタイル)。
v-enter-active
: 要素が挿入されている間の状態(トランジション効果を指定)。
v-enter-to
: 要素が挿入された後の状態(アニメーション終了時のスタイル)。
v-leave-from
: 要素が削除される直前の状態(アニメーション開始時のスタイル)。
v-leave-active
: 要素が削除されている間の状態(トランジション効果を指定)。
v-leave-to
: 要素が削除された後の状態(アニメーション終了時のスタイル)。
これらのクラス名は、<transition name="my-transition">
のように name
プロパティを指定することでプレフィックス(v-
の部分)を変更できます(例: my-transition-enter-from
)。
Modal.vue にトランジションを追加
Modal.vue
のテンプレートで、オーバーレイ要素を <transition>
コンポーネントで囲みます。オーバーレイとコンテンツの両方に異なるトランジションを適用することも多いですが、ここではシンプルにオーバーレイ全体にフェードイン/アウトを適用し、コンテンツにわずかなスケールアップ/ダウンとフェードイン/アウトを組み合わせる例を示します。
“`vue
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
“`
解説
Modal.vue
のテンプレートで、.modal-overlay
要素を <transition name="modal-fade">
で囲みました。v-if="show"
は <transition>
の子要素にそのまま残します。
- CSSでは、
modal-fade-enter-active
と modal-fade-leave-active
クラスで opacity
に対する transition
プロパティを設定し、オーバーレイのフェードイン/アウトアニメーションを定義しています。
.modal-container
自体にも opacity: 0; transform: scale(0.95);
という初期状態と、transition: all 0.3s ease;
を設定しています。これにより、オーバーレイのフェードと同時に、モーダルコンテンツが少し拡大しながらフェードインし、縮小しながらフェードアウトするアニメーションになります。
- より細かい制御が必要な場合、
.modal-fade-enter-from .modal-container
のように、トランジションクラスとモーダルコンテナのクラスを組み合わせてスタイルを定義することも可能です。これにより、オーバーレイが表示/非表示されるタイミングで、ネストされた要素(モーダルコンテナ)のアニメーションを開始できます。
これで、モーダルの開閉時にスムーズなアニメーションが加わり、ユーザー体験が向上しました。
セクション 5:アクセシビリティ (A11y) への配慮
モーダルは、特に視覚障碍を持つユーザーやキーボード操作のみを行うユーザーにとって、適切に実装されていないと大きな障壁となり得ます。アクセシビリティ(A11y)に配慮したモーダルは、全てのユーザーがアプリケーションを快適に利用できるようにするために不可欠です。
主な配慮点は以下の通りです。
-
キーボード操作:
- Escapeキーで閉じる: 多くのユーザーがEscキーでモーダルを閉じることができると期待しています。これは既に
Modal.vue
で実装しました。
- フォーカストラップ: モーダルが開いている間は、モーダル内の要素にのみキーボードフォーカスが移動し、モーダル外の要素にフォーカスが移動しないようにする必要があります。
- 初期フォーカス: モーダルが開かれた際に、最初のインタラクティブ要素(ボタン、フォーム入力など)に自動的にフォーカスを移動させると、キーボードユーザーがすぐに操作を開始できます。
- 閉じた後のフォーカス: モーダルが閉じられた後、モーダルを開いた要素(ボタンなど)にフォーカスを戻すのが一般的です。
-
ARIA属性: スクリーンリーダーなどの支援技術にモーダルであることを正しく伝えるために、適切なARIA属性を使用します。
role="dialog"
または role="alertdialog"
: モーダル要素(.modal-container
)に設定します。dialog
は一般的なモーダル、alertdialog
はユーザーに重要な情報を伝え、操作の判断を求める場合(確認ダイアログなど)に使用します。
aria-modal="true"
: モーダル要素に設定し、これがモーダル(背景のコンテンツを操作不能にする)であることを支援技術に伝えます。
aria-labelledby
: モーダル要素に設定し、モーダルのタイトルとして機能する要素のIDを指定します。
aria-describedby
: モーダル要素に設定し、モーダルの説明や主要な内容として機能する要素のIDを指定します(必須ではないが、複雑なモーダルで役立つ)。
- 背景コンテンツの非表示: モーダルが表示されている間、その下の背景にあるメインコンテンツに
aria-hidden="true"
を設定することで、スクリーンリーダーが背景の内容を読み上げないようにします。これはアプリケーションのメインコンテナ要素などに対して行います。
-
スクロールロック: モーダルが表示されている間、背景のコンテンツがスクロールしないようにします。これは既にModal.vue
でdocument.body.style.overflow = 'hidden'
という簡易的な方法で実装しました。
アクセシビリティ対応の強化
フォーカストラップの実装は少し複雑になります。完全に機能するフォーカストラップをゼロから実装するのは手間がかかるため、多くの場合はアクセシビリティに対応したモーダルライブラリを利用するのが現実的です。しかし、ここでは基本的な考え方と、最低限の改善策を示します。
フォーカストラップの基本的な考え方:
- モーダルが開いた際に、モーダル内の最初のインタラクティブ要素(またはモーダルコンテナ自体)にフォーカスを移動させる。
- モーダル内の最後のインタラクティブ要素にフォーカスがある状態でTabキーを押した場合、モーダル内の最初のインタラクティブ要素にフォーカスを戻す。
- モーダル内の最初のインタラクティブ要素にフォーカスがある状態でShift + Tabキーを押した場合、モーダル内の最後のインタラクティブ要素にフォーカスを移動させる。
- モーダルが閉じられた際に、モーダルを開くトリガーとなった要素にフォーカスを戻す。
これらの処理はJavaScriptを使って行いますが、要素の特定やキーイベントのハンドリングが複雑になりがちです。
Modal.vueへの最低限のアクセシビリティ強化(Escキー、ARIA属性、スクロールロック)は既に実装済みです。
コード例として、フォーカスの管理を行うwatchを追加してみます。ただし、これは非常に基本的な実装であり、全てのケースに対応できるわけではありません。より堅牢な実装にはライブラリの使用を推奨します。
“`vue
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
“`
アクセシビリティ強化のポイント
role="dialog"
, aria-modal="true"
, :aria-labelledby="title ? 'modalTitle' : null"
を .modal-container
に追加しました。これにより、スクリーンリーダーにこれがモーダルであり、どの要素がタイトルかを伝えます。タイトル要素には対応する id="modalTitle"
を追加しています。
- 閉じるボタンに
aria-label="Close Modal"
を追加し、ボタンの役割を明確に伝えます。
document.body.style.overflow = 'hidden';
で背景スクロールを無効化します。
document.getElementById('app').setAttribute('aria-hidden', 'true');
のように、モーダルが表示されている間、アプリケーションのメインコンテンツをスクリーンリーダーから隠します。
modalContainerRef
を使ってモーダルコンテナへの参照を取得し、nextTick
を利用してDOM更新後にモーダル内の最初のフォーカス可能な要素にフォーカスを移動させています。また、フォーカス可能な要素がない場合はモーダルコンテナ自体に tabindex="-1"
を設定してフォーカス可能にし、そこにフォーカスを移すようにしています。
- モーダルを開いた要素 (
document.activeElement
) を記録し、モーダルが閉じた後にその要素にフォーカスを戻すようにしています。
注意: 上記のフォーカス管理コードはあくまで基本的なデモンストレーションです。より洗練されたフォーカストラップ、特にTabキーによるループや、様々な種類のインタラクティブ要素への対応は、より複雑なロジックが必要です。本格的なアプリケーションでは、Vue用のアクセシブルなモーダルライブラリ(例: vue-final-modal
, vue-accessible-modal
など)の利用を検討すべきです。
セクション 6:状態管理とモーダルマネージャー
これまでの再利用可能なモーダルコンポーネントは、親コンポーネントがそれぞれのモーダルの表示状態 (showModal
のようなデータプロパティ) を管理していました。これは多くのシナリオで十分に機能しますが、以下のような場合には課題が生じます。
- アプリケーションの深い階層にあるコンポーネントから、アプリケーション全体に関わるようなモーダル(例: セッション切れ通知、グローバル設定モーダル)を表示したい場合。Propsやイベントのバケツリレーが煩雑になる可能性があります。
- 表示するモーダルの種類が複数あり、どのモーダルをどのデータや設定で表示するかを集中管理したい場合。
- 非同期処理の結果としてモーダルを表示したい場合(例: API呼び出しの成否に応じたメッセージモーダル)。
これらの課題を解決するために、アプリケーションの状態管理(VuexまたはPinia)を利用してモーダルの表示を制御したり、モーダルマネージャーパターンを導入したりすることが有効です。
状態管理ストアでのモーダル状態管理(概念)
VuexやPiniaのような集中型ストアで、現在表示されているモーダルの情報(表示/非表示、モーダルの種類、渡すデータなど)を一元管理します。
ストアの例(Piniaを使用):
“`javascript
// stores/modal.js
import { defineStore } from ‘pinia’;
export const useModalStore = defineStore(‘modal’, {
state: () => ({
activeModal: null, // 現在表示中のモーダルの種類 (例: ‘info’, ‘confirm’, ‘login’)
modalProps: {}, // 現在表示中のモーダルに渡すPropsデータ
}),
actions: {
openModal(type, props = {}) {
this.activeModal = type;
this.modalProps = props;
},
closeModal() {
this.activeModal = null;
this.modalProps = {};
},
// 特定のモーダルを閉じる(Optional)
// closeModalIfType(type) {
// if (this.activeModal === type) {
// this.closeModal();
// }
// }
},
getters: {
isModalOpen: (state) => !!state.activeModal, // 何かモーダルが表示されているか
}
});
“`
使い方:
-
ストアでモーダルを開く: 任意のコンポーネントからPiniaストアのアクションを呼び出します。
“`vue
“`
2. ルートまたは最上位コンポーネントでの表示制御(モーダルマネージャーパターン):
アプリケーションのルートレベルまたは共有レイアウトコンポーネントに、ストアの状態を監視し、適切なモーダルコンポーネントを動的にレンダリングする部分を配置します。
“`vue
<!-- モーダルマネージャー部分 -->
<!-- isModalOpen(activeModalがnullでない)場合にモーダルを表示 -->
<!-- モーダルコンポーネント自体はactiveModalの種類に基づいて動的に切り替え -->
<ModalBase
v-if="modalStore.isModalOpen"
:show="modalStore.isModalOpen"
:title="modalStore.modalProps.title || ''"
:closable="modalStore.modalProps.closable !== false"
:closeOnClickOutside="modalStore.modalProps.closeOnClickOutside !== false"
:closeOnEsc="modalStore.modalProps.closeOnEsc !== false"
@close="modalStore.closeModal"
>
<!-- ★ ここで activeModal の種類に応じて表示するコンテンツを切り替える ★ -->
<template #header v-if="modalStore.activeModal === 'info' || modalStore.activeModal === 'confirm'">
<!-- デフォルトヘッダーを使用するか、カスタムヘッダーを記述 -->
<h3 v-if="modalStore.modalProps.title">{{ modalStore.modalProps.title }}</h3>
</template>
<template #default>
<!-- info モーダルのコンテンツ -->
<div v-if="modalStore.activeModal === 'info'">
<p>{{ modalStore.modalProps.message }}</p>
</div>
<!-- confirm モーダルのコンテンツ -->
<div v-if="modalStore.activeModal === 'confirm'">
<p>{{ modalStore.modalProps.text }}</p>
</div>
<!-- 他の種類のモーダルコンテンツ... -->
<div v-if="modalStore.activeModal === 'login'">
<p>ログインフォームをここに配置</p>
<!-- 実際のログインフォームコンポーネントをここに挿入 -->
<LoginForm @success="modalStore.closeModal" @cancel="modalStore.closeModal"/>
</div>
<!-- 未知のモーダルタイプの場合のデフォルト表示 -->
<div v-else>
<p>不明なモーダルタイプ: {{ modalStore.activeModal }}</p>
</div>
</template>
<template #footer v-if="modalStore.activeModal === 'confirm'">
<button @click="modalStore.modalProps.onCancel">キャンセル</button>
<button @click="modalStore.modalProps.onConfirm">確認</button>
</template>
<!-- 他の種類のモーダルフッター... -->
</ModalBase>
<!-- より洗練されたモーダルマネージャー:動的なコンポーネントを使用 -->
<!-- <ModalManager /> という専用コンポーネントを作成し、その中で below のロジックをカプセル化することも多い -->
<!-- <template v-if="modalStore.activeModal">
<component
:is="getModalComponent(modalStore.activeModal)"
v-bind="modalStore.modalProps"
@close="modalStore.closeModal"
/>
</template> -->
“`
モーダルマネージャーのパターン
上記の例のように、単一の共通モーダルコンポーネント(ModalBase
)を使用し、そのスロット内で表示するコンテンツをストアの状態 (activeModal
) に応じて切り替える方法があります。これはシンプルで、共通のモーダルデザインを使用する場合に適しています。
より柔軟なモーダルマネージャーパターンとしては、表示するモーダル自体をストアの状態に応じて動的に切り替える方法があります。
- 各モーダルタイプを独立したコンポーネントにする:
InfoModal.vue
, ConfirmModal.vue
, LoginForm.vue
のように、モーダルコンテナを含む各モーダルタイプごとのコンポーネントを作成します。これらのコンポーネントは、共通のProps(show
など)とイベント(close
など)を持つように設計します。
- モーダルマネージャーコンポーネントの作成: アプリケーションのルートに配置する専用の
ModalManager.vue
コンポーネントを作成します。このコンポーネントはストアの activeModal
状態を監視し、Vueの <component :is="componentName">
機能を使って、対応するモーダルコンポーネントをレンダリングします。
例(概念):
“`vue
“`
そして、App.vue
などの最上位コンポーネントに <ModalManager />
を配置します。
この「モーダルマネージャー + 個別モーダルコンポーネント」パターンは、モーダルの種類が多く、それぞれが大きく異なるデザインやロジックを持つ場合に非常に有効です。ストアは「どのモーダルを表示するか」と「そのモーダルに何を渡すか」だけを管理し、個別のモーダルの実装はそれぞれのコンポーネントに委ねられます。
状態管理のメリットとデメリット
- メリット:
- アプリケーションのどこからでもモーダルを簡単にトリガーできる(Props/Eventsのバケツリレーが不要)。
- 表示するモーダルの種類やデータの一元管理が可能。
- 非同期処理との連携が容易(ストアのアクション内でAPI呼び出しを行い、その結果に応じてモーダルを開くなど)。
- デメリット:
- Vuex/Piniaストアの導入が必要になり、アプリケーションのアーキテクチャがやや複雑になる。
- ストアの変更はグローバルに影響するため、副作用に注意が必要。
シンプルなアプリケーションや、モーダルが特定のコンポーネント階層内でしか使用されない場合は、Props/Eventsによるローカルな状態管理で十分かもしれません。しかし、大規模なアプリケーションや、モーダルがアプリケーション全体に関わる場合は、状態管理の導入を検討する価値があります。
セクション 7:Teleport を使用したモダンな実装 (Vue 3+)
これまでのモーダル実装では、モーダル要素(オーバーレイとコンテンツ)は、モーダルを表示する親コンポーネントのDOMツリー内に直接レンダリングされていました。これは多くの場合問題ありませんが、以下のような課題を引き起こす可能性があります。
- スタイリング/Z-indexの問題: モーダルは通常、他の全ての要素の上に表示される必要がありますが、親要素の
position
や z-index
プロパティ、あるいはCSSのスタッキングコンテキストによって、意図した通りに最前面に表示されないことがあります。
- DOM構造のクリーンさ: アプリケーションのルート要素のDOMツリーが、アプリケーション本体のコンテンツと関係のないモーダル要素によって複雑になる可能性があります。
- CSSの影響: モーダルやそのオーバーレイに適用したCSSが、誤って親コンポーネントや他の要素に影響を与えてしまう可能性があります(特にスコープ付きCSSを使用しない場合)。
Vue 3で導入された <Teleport>
コンポーネントは、これらの課題を解決するために設計されました。<Teleport>
を使うと、コンポーネントの一部(または全体)を、DOMツリー内の指定した別の場所にレンダリングすることができます。
モーダルとの関連では、モーダルコンポーネントの内容をアプリケーションのルート要素 (#app
) の外、例えば <body>
タグの直下にレンダリングすることが一般的です。これにより、モーダルは親コンポーネントのDOM構造やスタイルに影響されることなく、常にDOMの最上位付近に配置され、z-index
の問題などが解決しやすくなります。
Teleport を使用した Modal.vue の修正
既存の Modal.vue
コンポーネントを修正して <Teleport>
を使用します。
“`vue
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</transition>
“`
Teleport の解説
to="body"
: <Teleport>
コンポーネントに to
Propを指定します。この値はCSSセレクタ(例: #some-id
, .some-class
, body
)または実際のDOM要素です。指定されたセレクタに一致する最初の要素の子として、<Teleport>
内のコンテンツがレンダリングされます。ここでは body
を指定することで、モーダルのDOM要素が <body>
タグの直下に配置されるようになります。
v-if="show"
: <transition>
コンポーネントと組み合わせる場合、v-if
は <transition>
の内側に配置するのが最も一般的なパターンです。これにより、モーダルが表示/非表示される際にトランジションがトリガーされます。
- スタイルの適用:
<Teleport>
で要素を別の場所にレンダリングしても、元のコンポーネントで定義されたCSS(特に scoped
スタイル)は正しく適用されます。つまり、Modal.vue
で定義したスコープ付きCSSは、body
タグの直下にレンダリングされたモーダル要素にも適用され続けます。
Teleport のメリットとデメリット
- メリット:
- DOM構造の分離: モーダル要素がアプリケーションのメインコンテンツのDOMツリーから分離されるため、構造がよりクリーンになります。
- Z-index/スタイリングの問題解消: モーダルは通常DOMの最上位近くに配置されるため、他の要素との重なり順序(Z-index)の問題が発生しにくくなります。
- 再利用性の向上: コンポーネントをどこに配置しても、モーダル要素は常に指定されたターゲット(例: body)にレンダリングされるため、親コンポーネントのDOM構造に依存しません。
- デメリット:
- Vue 3以降でのみ利用可能です。
- DOM検査時に、要素が元のコンポーネントの場所ではなく、Teleport先の場所にレンダリングされていることを理解する必要があります。
Vue 3以降を使用している場合、モーダルやドロップダウン、ツールチップなど、アプリケーションの他の部分の上に重ねて表示される要素には、<Teleport>
を積極的に使用することを検討すべきです。これにより、実装がよりシンプルになり、多くの潜在的なレイアウトやスタッキングコンテキストの問題を回避できます。
セクション 8:その他の考慮事項と高度なテクニック
スクロールロックのより堅牢な方法
document.body.style.overflow = 'hidden';
は最も簡単なスクロールロック方法ですが、いくつかの問題があります。
- スクロールバーが消えたときにコンテンツがガタつく(特にWindowsなどスクロールバーの幅が大きいOS)。
- すでにスクロールバーがある場合に意図しないレイアウト変更を引き起こす可能性がある。
- iOS Safariなどで完全にスクロールを無効化できない場合がある。
より堅牢な方法としては、CSSでpadding-right
を調整してスクロールバーの幅を補完したり、JavaScriptでスクロール位置を保持・復元したりする方法がありますが、実装は複雑です。多くのUIライブラリはこれらの課題に対応したスクロールロック機能を提供しています。必要であれば、専用のライブラリ(例: body-scroll-lock
)を使用することも検討できます。
複数のモーダルの管理
同時に複数のモーダルを開く必要がある場合は、複雑さが増します。
- 単純な重ね合わせ: 各モーダルが高いZ-indexを持ち、単に重ねて表示されます。ユーザーは一番手前のモーダルのみ操作できます。
- モーダルスタック: 開いた順序を管理し、一番上のモーダル以外は非表示にするか、操作不能にします。これは状態管理ストアで開いているモーダルを配列で管理するなどの方法で実現できます。
ほとんどの一般的なUIでは、同時に複数のモーダルを開くのは避けるべきユーザー体験パターンです。通常は、一つのモーダルを閉じてから次のモーダルを開くようにします。
フォームを含むモーダル
モーダル内にフォームを配置する場合、フォームの入力状態管理、バリデーション、送信処理なども考慮する必要があります。これは通常、モーダルコンポーネントのスロットを使って、フォームコンポーネント自体をモーダルに挿入する形で行います。フォームコンポーネントは、入力されたデータやバリデーション結果をイベントでモーダルコンポーネント(またはその親)に通知し、送信処理は親または状態管理ストアで行います。
モーダルライブラリの活用
Vueエコシステムには、高機能でアクセシビリティにも配慮されたモーダルライブラリが多数存在します。
- Vuetify, Quasar, Element Plus, Ant Design Vue: これらの主要なUIフレームワークには、完成度の高いモーダル(ダイアログ)コンポーネントが含まれています。
- 独立したモーダルライブラリ:
vue-final-modal
vue-js-modal
(やや古い)
@headlessui/vue
の Dialog
コンポーネント (ヘッドレスUI、スタイルは自分で適用)
ゼロからフルスクラッチで全てを実装するのは学習になりますが、特にアクセシビリティや様々なエッジケースへの対応を考えると、信頼できるライブラリを使用する方が効率的で安定した結果を得られることが多いです。ライブラリを使用する場合でも、Props、Events、Slots、Teleportといった基本的な概念を理解しておくことは、ライブラリを効果的に使用するために非常に重要です。
セクション 9:まとめとベストプラクティス
Vueでモーダルを作成・表示する方法は、アプリケーションの要件や複雑さに応じていくつかの段階があります。
- 基本的な表示/非表示 (
v-if
/ v-show
): 最もシンプルですが、コードの重複や再利用性の問題があります。簡単なプロトタイプや一度きりの要素以外には推奨されません。
- 再利用可能なコンポーネント化: モーダルをProps, Events, Slotsを持つ独立したコンポーネントとして作成する方法です。これがVueでのモーダル実装の基本的なパターンであり、ほとんどのケースで推奨されます。これにより、コードの保守性、可読性、再利用性が大幅に向上します。
- アニメーション (
<transition>
): <transition>
コンポーネントとCSSを組み合わせることで、開閉時にスムーズなアニメーションを追加できます。
- アクセシビリティ (A11y): ARIA属性、キーボード操作(Escキー、フォーカストラップ)、スクロールロックなどの配慮は、全てのユーザーが利用できるアプリケーションのために不可欠です。特にフォーカストラップは複雑になるため、ライブラリの利用も検討します。
- 状態管理とモーダルマネージャー: 複数のモーダルタイプ、深いコンポーネント階層からの表示、非同期処理との連携が必要な場合は、Vuex/Piniaなどの状態管理ストアと組み合わせたモーダルマネージャーパターンが有効です。
- Teleport (Vue 3+): モーダル要素をDOMツリー内の別の場所にレンダリングすることで、スタイリングやZ-indexの問題を解決し、DOM構造をクリーンに保つためのモダンなアプローチです。Vue 3以降では積極的に使用を検討すべきです。
推奨されるベストプラクティス
- モーダルは常に再利用可能なコンポーネントとして実装する: これが基本中の基本です。Props、Events、Slotsを適切に使用して柔軟性を持たせます。
- 状態は親コンポーネントまたはストアで管理し、子(モーダル)はEventsで通知する (
Props down, Events up
): モーダルコンポーネント自身が表示状態を持つのではなく、親から受け取るようにします。
- Vue 3以降ではTeleportを使用する: モーダルコンポーネントのテンプレートを
<Teleport to="body">
で囲み、DOMツリーを分離します。
- アクセシビリティを最優先で考慮する: ARIA属性、Escキー、フォーカストラップ、スクロールロックは必須です。必要であればアクセシビリティに対応したライブラリを使用します。
- トランジションを追加してユーザー体験を向上させる:
<transition>
コンポーネントを活用します。
- 複数のモーダルタイプや複雑な制御が必要な場合は、状態管理 + モーダルマネージャーパターンを検討する: 単一のモーダルコンポーネントでslotsを使ってコンテンツを切り替えるか、
<component :is="...">
でモーダルコンポーネント自体を切り替えるパターンがあります。
- 既存のUIライブラリやモーダル専用ライブラリの利用も視野に入れる: 特に時間がない場合や、複雑なアクセシビリティ対応が必要な場合は、既存の成熟したライブラリが強力な助けになります。
この記事を通して、Vueでモーダルを効果的に、かつモダンな方法で実装するための様々な選択肢と、それぞれのメリット・デメリットを理解できたことでしょう。これらの知識を活用して、ユーザーにとって使いやすく、開発者にとって保守しやすいモーダルを実装してください。