🐕
v-cloakで“既読判定が終わるまで完全非表示”にするモーダル実装(Vue 3 + TS + Laravel Blade + Vite)
v-cloak版 “既読判定が終わるまで完全非表示”にする安全なモーダル実装(Vue 3 + TS + Laravel Blade + Vite)
対象読者
- Vue 3(Composition API)+ TypeScript でモーダルを実装したい方
- リロード時のチラつき(瞬間表示→即非表示) をなくしたい方
- Blade(Laravel)×Vite 構成で動かしたい方
- 初学者向け:専門用語は噛み砕いて解説します
ゴール
- レスポンシブ対応で常に画面中央に表示されるモーダル
- 背景は暗幕でブロック、開いている間はページスクロール不可
- localStorageの既読判定が終わるまで完全非表示(v-cloak でチラつきゼロ)
- 既読なら最初から表示しない/未読なら表示して保存
ディレクトリ構成(例)
resources/
views/
index.blade.php
partials/
charge_modal.blade.php # ← モーダルのパーシャル
ts/
chargeModal.ts # ← Vue 3 + TS(ロジック)
scss/
charge-modal.scss # ← 見た目(レイアウト)
1) Blade パーシャル(HTML)
resources/views/partials/charge_modal.blade.php
ポイント:
-
v-cloakでVueがマウント&既読判定完了(ready)するまで完全非表示 - 表示切替は
v-show="ready && open"(DOMを破棄しない=安定)
<style>
/* Vueがマウントするまで完全に隠す(チラつき対策) */
[v-cloak] { display: none !important; }
</style>
<div id="charge-modal-root" v-cloak data-user-id="{{ Auth::id() ?? '' }}">
<div
class="cm-underlay"
v-show="ready && open"
@click.self="close"
role="dialog"
aria-modal="true"
aria-labelledby="cm-title"
>
<div class="cm-dialog" role="document">
<button type="button" class="cm-close" @click="close" aria-label="閉じる">×</button>
<div class="cm-body">
<p id="cm-title" class="cm-title">月額料金改定のお知らせ</p>
<p class="cm-desc">2025年12月1日より</p>
<div class="cm-actions">
<a class="cm-btn" href="/explanation/fee_notice">
<span>詳しくはコチラ</span>
<span class="cm-btn-icon">▶︎</span>
</a>
</div>
</div>
</div>
</div>
</div>
初学者メモ
@click.self="close"は暗幕(underlay)自体をクリックしたときだけ閉じます(ダイアログ内クリックでは閉じない)。aria-*属性はスクリーンリーダー向けのヒントです(アクセシビリティ対応)。
2) SCSS(レイアウト & レスポンシブ)
resources/scss/charge-modal.scss
ポイント:
-
position: fixed + flexで常に中央寄せ(top/left/% + transformより頑丈) -
width: min(92vw, 600px)でモバイル〜PCの最適幅 -
暗幕は underlay の背景のみ(
bodyには opacity を掛けない)
.cm-underlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,.45);
z-index: 10000;
}
.cm-dialog {
position: relative;
width: min(92vw, 600px);
max-height: min(92vh, 680px);
overflow: auto;
background: #fff;
border-radius: 12px;
border: 1px solid #fff;
box-shadow: 0 10px 30px rgba(0,0,0,.25);
padding: 24px 20px 28px;
animation: cm-pop .16s ease-out;
@keyframes cm-pop {
from { transform: scale(.98); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
}
.cm-body { text-align: center; }
.cm-title { font-size: clamp(18px,2.6vw,20px); font-weight: 700; color: #222; margin: 0 0 .5rem; }
.cm-desc { font-size: clamp(14px,2.2vw,16px); color: #333; margin: .25rem 0 1.25rem; }
.cm-actions{ display: flex; justify-content: center; }
.cm-btn {
position: relative;
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .65rem 2.4rem .65rem 1.4rem;
border-radius: 999px;
background: #7030A0;
border: 1px solid #7030A0;
color: #fff;
text-decoration: none;
font-size: clamp(14px,2.2vw,16px);
}
.cm-btn-icon { position: absolute; right: .9rem; top: 50%; transform: translateY(-50%); }
.cm-close {
position: absolute;
top: .5rem; right: .5rem;
width: 36px; height: 36px;
display: grid; place-items: center;
background: transparent; border: none; color: #555;
font-size: 1.6rem; line-height: 1; cursor: pointer;
}
3) TypeScript(Vue 3 + Vite)
resources/ts/chargeModal.ts
ポイント:
-
readyは「既読判定が完了した」合図(これを true にしてv-cloak解除) -
openは「実際に開くか」の状態(未読→true/既読→false) -
watch で
open中のみbody{overflow:hidden}(背景スクロール抑止) - Escキーで閉じる
import { createApp, ref, onMounted, watch } from "vue";
const root = document.getElementById("charge-modal-root");
if (root) {
createApp({
setup() {
const ready = ref(false); // 既読チェックが終わったら true(= v-cloak解除OK)
const open = ref(false); // 実際に開くかどうか
const close = () => { open.value = false; };
const show = () => { open.value = true; };
const storageKey = (uid: string | null) =>
`charge_modal_shown_user_${uid ?? "guest"}`;
onMounted(() => {
// 1) 既読確認(ここが終わるまで v-cloak で隠す)
const uid = root.getAttribute("data-user-id");
const key = storageKey(uid);
const seen = localStorage.getItem(key) === "true";
if (!seen) {
show(); // 未読なら表示
localStorage.setItem(key, "true");
} else {
open.value = false; // 既読なら非表示
}
// 2) キー操作(Escで閉じる)
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
window.addEventListener("beforeunload", () =>
window.removeEventListener("keydown", onKey)
);
// 3) 判定が終わったので表示許可(v-cloak解除)
ready.value = true;
});
// 開いている間だけスクロール抑止
watch(open, (v) => {
document.body.style.overflow = v ? "hidden" : "";
}, { immediate: true });
return { ready, open, close };
},
}).mount(root);
}
4) index.blade.php から呼び出す
4-1. パーシャルを読み込む
resources/views/index.blade.php(任意のページ)
@extends('layouts.app')
@section('content')
{{-- ページの中身… --}}
{{-- ログイン時だけ出す場合 --}}
@if (Auth::check())
@include('partials.charge_modal')
@endif
@endsection
@push('vite')
@vite(['resources/scss/charge-modal.scss', 'resources/ts/chargeModal.ts'])
@endpush
レイアウトが
@stack('vite')を持っていない場合は、@vite([...])をレイアウト側に追加するか、既存のエントリ(例:resources/ts/app.ts)からimportしてもOK。
5) 動作チェック(実務チェックリスト)
- ビルド/読込:Networkタブで SCSS/TS が 200 で配信されているか
-
DOM:
#charge-modal-rootが存在するか(Console で!!document.getElementById('charge-modal-root')) - 初回:モーダルが中央に表示される/背景スクロール不可
-
既読:
localStorageにcharge_modal_shown_user_*が保存され、次回は出ない - チラつき無し:リロード時に瞬間表示→非表示の現象がない(=v-cloak成功)
- 閉じ動作:×ボタン/暗幕クリック/Esc で閉じる&スクロール復帰
-
重なり:必要なら
.cm-underlay{ z-index: 11000; }に上げて他UIより前面へ
v-if / v-show / v-cloak の使い分け
- v-if:DOMを作ったり消したりする。再マウントが発生 → モーダルでは非推奨(チラつき・フォーカス喪失)
-
v-show:
displayの切替だけ。DOMは残る → モーダル向けで安定 - v-cloak:Vueがマウントしてバインディングが効くまでテンプレートを隠すためのフラグ → 初期チラつき対策の要
よくあるエラーと対処(初学者向け)
-
一瞬表示してすぐ消える
→v-cloakが無い/readyを判定後にtrueにしていない。本記事の順序通りに修正。 -
画面が暗いだけで何も出ない/操作不可
→ 別CSSが underlay を常時表示にしている/pointer-eventsを奪っている。DevToolsの「計算済みスタイル」で.cm-underlayを確認。 -
中央に来ない・ズレる
→ 別CSSがposition/displayを上書き。.cm-underlayにposition: fixed; display: flex;が効いているか確認。 -
本番だけ崩れる
→ ファイル名の大文字小文字違い(Linuxは厳密)。charge_modal.blade.phpの表記統一。
参考(公式ドキュメント)
-
Vue 3 公式
- 条件付きレンダリング(v-if / v-show):https://vuejs.org/guide/essentials/conditional.html
- ライフサイクル:https://vuejs.org/guide/essentials/lifecycle.html
- ウォッチャ:https://vuejs.org/guide/essentials/watchers.html
-
MDN
まとめ
- v-cloak で「既読判定が完了するまで完全非表示」にするのがチラつきゼロの鍵。
- 表示は
v-showで安定させ、underlay のみを半透明に。 -
overflow:hiddenは開いている時だけ付与・閉じたら必ず外す。 - Blade の
@includeと Vite の読込を整理すれば、index.blade.php から簡単に呼び出せる。
Discussion