🐕

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-cloakVueがマウント&既読判定完了(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)
  • watchopen 中のみ 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) 動作チェック(実務チェックリスト)

  1. ビルド/読込:Networkタブで SCSS/TS が 200 で配信されているか
  2. DOM#charge-modal-root が存在するか(Console で !!document.getElementById('charge-modal-root')
  3. 初回:モーダルが中央に表示される/背景スクロール不可
  4. 既読localStoragecharge_modal_shown_user_* が保存され、次回は出ない
  5. チラつき無し:リロード時に瞬間表示→非表示の現象がない(=v-cloak成功)
  6. 閉じ動作:×ボタン/暗幕クリック/Esc で閉じる&スクロール復帰
  7. 重なり:必要なら .cm-underlay{ z-index: 11000; } に上げて他UIより前面へ

v-if / v-show / v-cloak の使い分け

  • v-if:DOMを作ったり消したりする。再マウントが発生 → モーダルでは非推奨(チラつき・フォーカス喪失)
  • v-showdisplay の切替だけ。DOMは残る → モーダル向けで安定
  • v-cloak:Vueがマウントしてバインディングが効くまでテンプレートを隠すためのフラグ → 初期チラつき対策の要

よくあるエラーと対処(初学者向け)

  • 一瞬表示してすぐ消える
    v-cloak が無い/ready を判定後に true にしていない。本記事の順序通りに修正
  • 画面が暗いだけで何も出ない/操作不可
    → 別CSSが underlay を常時表示にしている/pointer-events を奪っている。DevToolsの「計算済みスタイル」で .cm-underlay を確認。
  • 中央に来ない・ズレる
    → 別CSSが position / display を上書き。.cm-underlayposition: fixed; display: flex; が効いているか確認。
  • 本番だけ崩れる
    → ファイル名の大文字小文字違い(Linuxは厳密)。charge_modal.blade.php の表記統一。

参考(公式ドキュメント)


まとめ

  • v-cloak で「既読判定が完了するまで完全非表示」にするのがチラつきゼロの鍵
  • 表示は v-show で安定させ、underlay のみを半透明に。
  • overflow:hidden は開いている時だけ付与・閉じたら必ず外す。
  • Blade の @include と Vite の読込を整理すれば、index.blade.php から簡単に呼び出せる

Discussion