【Vue.js】トースト通知を作る方法|イベントで表示・非表示を制御する簡易実装

【Vue.js】トースト通知を作る方法|イベントで表示・非表示を制御する簡易実装 Vue.js

トースト通知は、保存の成功やエラーなどを、画面の端に短く知らせるUIです。ページ遷移なしでフィードバックを返せるため、よく使われます。基本の作り方は簡単ですが、続けて通知したときの動きや、読み上げソフトへの対応で、つまずきやすい部分があります。この記事では、Vue 3を前提に、複数の通知を、アプリのどこからでも呼べる形に作ります。

スポンサーリンク

最初の実装の問題点

「状態をrefで持ち、setTimeoutで3秒後に消す」という書き方は、シンプルでよく見かけます。ただ、実際に使うと問題が出ます。

Vue:よくある簡易実装
<script setup>
import { ref } from 'vue'

const isVisible = ref(false)
const message = ref('')

const showToast = (msg) => {
  message.value = msg
  isVisible.value = true
  setTimeout(() => {
    isVisible.value = false     // ← 前のタイマーが残っていると、後の通知まで道連れに消える
  }, 3000)
}
</script>

<template>
  <button @click="showToast('保存しました!')">通知を表示</button>
  <div v-if="isVisible" class="toast">{{ message }}</div>   <!-- ← 要素ごと出し入れ -->
</template>
  • 続けて通知すると、2回目が早く消える:前のsetTimeoutが残るため、たとえば1秒後にもう一度押すと、最初のタイマーが、2回目の通知まで道連れに消してしまいます。
  • 読み上げソフトに伝わらない:v-ifで要素ごと出し入れしているためです。
  • フェードアウトが効かない:transition: opacityを書いていても、要素が一瞬で消えるため、動きません。
  • 1度に1つしか出せず、閉じる手段もない:表示時間が一律3秒では、読み切れない利用者もいます。

設計:状態、トースト1つ、コンテナ

次の3つに分けて作ります。

  • useToast():トーストの一覧と、追加・削除の関数を持つComposable。どこからでも呼べる。
  • ToastItem.vue:トースト1つ。自分のタイマーを自分で管理する。
  • ToastContainer.vue:一覧を並べて表示する。アプリに1つだけ置く。

useToast:どこからでも呼べる共有の状態

一覧のrefを、関数の外(モジュールのスコープ)に置くのがポイントです。どのコンポーネントからuseToast()を呼んでも、同じ一覧を共有するので、グローバルイベントなどの仕組みを使わずに、どこからでも通知を出せます。Composableの基本はComposable関数でロジックを共通化する方法をご覧ください。

JavaScript:composables/useToast.js
// composables/useToast.js
import { ref } from 'vue'

// 関数の「外」に置くので、どこから呼んでも同じ一覧を共有する
const toasts = ref([])
let nextId = 1                       // Date.now() ではなく連番(同時に作っても重複しない)

export function useToast() {
  function show(message, { type = 'info', duration = 4000 } = {}) {
    const id = nextId++
    toasts.value.push({ id, message, type, duration })
    return id
  }

  function remove(id) {
    toasts.value = toasts.value.filter((toast) => toast.id !== id)
  }

  return {
    toasts,
    show,
    remove,
    success: (message, options) => show(message, { ...options, type: 'success' }),
    // エラーは読むのに時間がかかるので、長めに表示する
    error: (message, options) => show(message, { duration: 8000, ...options, type: 'error' })
  }
}

IDには、Date.now()ではなく連番を使います。同じミリ秒に2つ作ると、keyが重複するためです。

ToastItem:タイマーは1つずつ、自分で管理する

タイマーを、各トーストが自分で持つようにすると、複数の通知が互いに干渉しません。ホバーやフォーカス中は、タイマーを止めて残り時間を覚え、外れたら再開します。コンポーネントが破棄されるときは、必ずタイマーを片付けます。

Vue:ToastItem.vue
<!-- ToastItem.vue:1つのトースト。自分のタイマーを自分で管理する -->
<script setup>
import { onMounted, onBeforeUnmount } from 'vue'

const props = defineProps({
  toast: { type: Object, required: true }
})
const emit = defineEmits(['close'])

let timerId = null
let remaining = props.toast.duration   // 残り時間
let startedAt = 0

function start() {
  if (timerId !== null) return
  startedAt = Date.now()
  timerId = setTimeout(() => emit('close'), remaining)
}

// ホバーやフォーカス中は、タイマーを止めて、残り時間を覚えておく
function pause() {
  if (timerId === null) return
  clearTimeout(timerId)
  timerId = null
  remaining -= Date.now() - startedAt
}

onMounted(start)
onBeforeUnmount(() => clearTimeout(timerId))   // 破棄されるときは、必ずタイマーを片付ける
</script>

<template>
  <div
    class="toast"
    :class="`toast--${toast.type}`"
    :role="toast.type === 'error' ? 'alert' : undefined"
    @mouseenter="pause"
    @mouseleave="start"
    @focusin="pause"
    @focusout="start"
  >
    <span>{{ toast.message }}</span>
    <button type="button" aria-label="通知を閉じる" @click="emit('close')">×</button>
  </div>
</template>

ToastContainer:読み上げとアニメーション

コンテナは、トーストが1つもないときも、DOMに存在します。MDNによると、role="status"の領域は、暗黙でaria-live="polite"を持ち、内容が追加されると、ユーザーの操作の区切りに読み上げられます。ただし、読み上げを確実にするには、領域の要素を、内容を入れる前から用意しておく必要があります。そのため、v-ifでコンテナごと出し入れせず、コンテナは常に置いて、中のトーストだけを増減させます。

エラーのような緊急性の高い通知には、role="alert"を付けます。statusは、ユーザーが手を止めたタイミングで読み上げられ、alertは、割り込んですぐに読み上げられます。

アニメーションには<TransitionGroup>を使います。リストの出入りに合わせて、フェードとスライドが効きます。基礎はtransitionコンポーネントを使ってアニメーションを実装する方法をご覧ください。

Vue:ToastContainer.vue
<!-- ToastContainer.vue:アプリに1つだけ置く -->
<script setup>
import { useToast } from './composables/useToast'
import ToastItem from './ToastItem.vue'

const { toasts, remove } = useToast()
</script>

<template>
  <Teleport to="body">
    <!-- このコンテナは、トーストが無いときも DOM に存在する。
         role="status" の領域に追加された内容が、読み上げられる -->
    <div class="toast-container" role="status">
      <TransitionGroup name="toast">
        <ToastItem
          v-for="toast in toasts"
          :key="toast.id"
          :toast="toast"
          @close="remove(toast.id)"
        />
      </TransitionGroup>
    </div>
  </Teleport>
</template>

<style>
.toast-container {
  position: fixed;
  top: 20px;
  right: 20px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  z-index: 9999;
}
.toast {
  display: flex;
  gap: 1rem;
  align-items: center;
  padding: 0.8rem 1.2rem;
  color: #fff;
  background: #333;
  border-radius: 5px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.toast--success { background: #2e7d32; }
.toast--error { background: #c62828; }
.toast button { color: inherit; background: none; border: none; cursor: pointer; }

/* TransitionGroup のクラス。v-if で消えるので、ここで初めてフェードが効く */
.toast-enter-active,
.toast-leave-active {
  transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast-enter-from,
.toast-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
</style>

コンテナは、<Teleport to="body">で、body直下に描画します。深い階層の祖先にtransformなどがあっても、position: fixedの位置が崩れません。詳しくはモーダルウィンドウを作成する方法をご覧ください。

使い方

コンテナは、App.vueに1度だけ置きます。

Vue:App.vue
<!-- App.vue:ToastContainer を、1度だけ置く -->
<script setup>
import ToastContainer from './components/ToastContainer.vue'
</script>

<template>
  <RouterView />
  <ToastContainer />
</template>

通知を出したいコンポーネントでは、useToast()を呼ぶだけです。

Vue:どこからでも通知を出す
<script setup>
import { useToast } from '@/composables/useToast'

const toast = useToast()

function save() {
  toast.success('保存しました')
}
function fail() {
  toast.error('保存に失敗しました。もう一度お試しください。')
}
</script>

<template>
  <button type="button" @click="save">保存</button>
  <button type="button" @click="fail">失敗させる</button>
</template>

グローバルイベントで呼び出す方法との違い

どこからでも通知を呼び出す方法として、windowにCustomEventを発行して、コンテナが受け取る、という実装もあります。動きますが、windowというグローバルな場所に依存し、サーバーサイドレンダリングとは相性がよくありません。型も付けにくくなります。Vueでは、この記事のように、共有する状態をComposableで持つほうがシンプルです。アプリ全体の状態として扱いたい場合は、Piniaを使った状態管理の始め方のようなストアを使う選択肢もあります。

よくある質問(FAQ)

Q続けて通知すると、2回目の通知がすぐ消えてしまいます。
A前のタイマーが残っているのが原因です。clearTimeoutで止めるか、この記事のように、トーストごとに自分のタイマーを管理してください。
Q読み上げソフトが通知を読み上げません。
A通知の要素をv-ifで出し入れしていると、読み上げられない場合があります。role="status"を付けたコンテナを、あらかじめ置いておき、その中にトーストを追加してください。緊急のものはrole="alert"です。
Q表示時間は、何秒がよいですか?
Aメッセージの長さによります。短い成功の通知は数秒、エラーは長めにします。固定の時間では、読み切れない利用者もいるため、閉じるボタンと、ホバーやフォーカス中の一時停止を用意しておくと安心です。
Qwindow.dispatchEventで通知を呼ぶ方法は、使ってはいけませんか?
A使えますが、グローバルなwindowに依存し、型も付けにくくなります。共有する状態をComposableで持つ方法が、Vueではシンプルです。

まとめ

トースト通知は、一覧を共有するComposable、タイマーを自分で管理するトースト1つ、常に存在するコンテナ、の3つで作ると、複数の通知が干渉せず、どこからでも呼べます。role="status"とrole="alert"で読み上げに対応し、閉じるボタンと一時停止を用意すれば、利用者に優しい通知になります。