Svelte でトースト通知を作成する
Svelteのトースト通知をwritableストアで作る方法と、svelte-sonnerを使う方法を、Svelte 5構文、アクセシビリティ、自動消去まで解説。
Svelte におけるトースト通知とは、UI の上に一時的に表示される小さなメッセージのことで、アクションの完了を確認したりエラーを報告したりした後、一定時間が経つと自動的に消えるものです。
多くの開発者は、フォーム送信が成功したのに画面が何も起こらなかったかのように静止しているのを見て、深夜にこれを書くはめになります。選択肢は 2 つあります。writable ストアとコンテナコンポーネントを使って軽量なシステムを自前で構築するか、メンテナンスされているライブラリを導入するかです。本記事では両方のアプローチをコピー&ペースト可能なコードとともに紹介し、アクセシビリティについても触れ、さらに古い Svelte 4 のチュートリアルで使われている構文が Svelte 5 で非推奨になっている箇所を指摘します。
以下の内容はすべて、現行の安定メジャーバージョンである Svelte 5(2024年10月に安定版がリリース)を対象としています。Svelte 4 と異なる点については、その都度本文中で言及します。
要点
- Svelte 5 においてトーストのストアはほとんど変わりません(
writable([])は今も有効です)が、トーストコンポーネントは移行が必要です。export letは$propsに、on:clickはonclickに、<slot />は snippet に置き換わり、createEventDispatcherはコールバック props に取って代わられます。 - スクリーンリーダーにトーストを読み上げさせるには、
aria-liveを持つコンテナ内にレンダリングしてください(info/success にはpolite、エラーにはassertive)。個々のトーストに付けるrole="alert"は、このコンテナの代替手段であって併用するものではありません。両方を組み合わせると、1 つのメッセージが二重に読み上げられることがあります。 - 各トーストには
crypto.randomUUID()で衝突しない ID を付与し、手動で削除された際には自動消去タイマーをクリアしてください。そうすることで、手動で閉じたトーストが後から古い削除処理を発火させることがなくなります。 - svelte-sonner は
npm i svelte-sonnerでインストールし、アプリのルートで<Toaster />を一度だけレンダリングすれば、あとはどこからでもtoast()、toast.success()、toast.error()、toast.promise()で呼び出せます。 - 依存ゼロと完全なコントロールを求めるなら自前実装を、promise トースト・スワイプ操作での消去・テーマ設定・アクセシビリティを最初から備えたものが欲しいなら svelte-sonner を選びましょう。
トーストとは何か、いつ使うべきか
トーストとは、一時的でノンブロッキングなフィードバックのことです。成功/エラー/情報メッセージが積み重なって表示され、タイマーで自動的に消え、モーダルのようにユーザーの操作を妨げることはありません。フォーム送信の確認、非同期処理のエラー表示、バックグラウンド処理の完了通知などにトーストを使いましょう。一方で、ユーザーが必ず何らかの操作をしなければならない内容や、見逃されては困る内容にトーストを使ってはいけません。そうした内容はインラインメッセージやダイアログで扱うべきです。トーストは読まれる前に自動で消えてしまう可能性があるからです。
Svelte でストアを使ってトーストシステムを構築するには
Discover how at OpenReplay.com.
自前実装のシステムの中核は、トーストオブジェクトの配列を保持する 1 つの writable ストアと、どこからでも呼び出せる addToast/dismissToast ヘルパーです。Svelte のストアは Svelte 5 でも引き続き利用できるため、このパターンは非推奨ではありません。より新しい .svelte.ts の runes を使うアプローチは慣用的ではありますが、必須ではありません。
// src/lib/toast-store.js
import { writable } from 'svelte/store';
export const toasts = writable([]);
const timers = new Map();
export function addToast(toast) {
const id = crypto.randomUUID();
const defaults = { id, type: 'info', dismissible: true, timeout: 3000 };
const t = { ...defaults, ...toast };
toasts.update((all) => [t, ...all]);
if (t.timeout) {
timers.set(id, setTimeout(() => dismissToast(id), t.timeout));
}
return id;
}
export function dismissToast(id) {
const timer = timers.get(id);
if (timer) {
clearTimeout(timer); // stop a stale auto-dismiss from firing later
timers.delete(id);
}
toasts.update((all) => all.filter((t) => t.id !== id));
}
ここで押さえておくべき正確性に関わるポイントが 2 つあります。ID は Math.random() ではなく crypto.randomUUID() から生成しているため、衝突が起こりません(ただし secure context、つまり HTTPS または localhost でのみ動作します)。また、各トーストのタイマーを Map で管理し、手動での消去時にクリアしているため、閉じるボタンをクリックしても、すでに削除されたトーストを指す setTimeout が残ることはありません。
続いてコンテナが配列を id をキーにしてレンダリングし、各トーストに消去用のコールバックを渡します。
<!-- src/lib/Toasts.svelte -->
<script>
import Toast from './Toast.svelte';
import { toasts, dismissToast } from './toast-store.js';
</script>
<section class="toast-container" role="region" aria-live="polite" aria-label="Notifications">
{#each $toasts as toast (toast.id)}
<Toast {...toast} ondismiss={() => dismissToast(toast.id)} />
{/each}
</section>
<style>
.toast-container {
position: fixed; top: 1rem; left: 0; right: 0;
display: flex; flex-direction: column; align-items: center;
gap: 0.5rem; z-index: 1000; pointer-events: none;
}
</style>
子コンポーネントの Toast.svelte は、全面的に Svelte 5 の書き方を採用しています。入力には $props()、イベントには onclick、そして消去にはコールバック props を使います。
<!-- src/lib/Toast.svelte (Svelte 5) -->
<script>
import { fade } from 'svelte/transition';
let { message, type = 'info', dismissible = true, ondismiss } = $props();
</script>
<article class="toast {type}" transition:fade>
<p>{message}</p>
{#if dismissible}
<button class="close" onclick={() => ondismiss?.()} aria-label="Dismiss notification">×</button>
{/if}
</article>
<style>
.toast { display: flex; gap: 1rem; width: 20rem; padding: 0.75rem 1.25rem;
border-radius: 0.25rem; color: white; pointer-events: auto; }
.info { background: SteelBlue; }
.success { background: SeaGreen; }
.error { background: IndianRed; }
.close { margin-left: auto; background: none; border: 0; color: inherit;
font-size: 1.25rem; cursor: pointer; }
</style>
ルートレイアウトに <Toasts /> を一度だけマウントしたら、あとはどこからでも呼び出せます。
import { addToast } from '$lib/toast-store.js';
addToast({ message: 'Saved!', type: 'success' });
Svelte 4 と Svelte 5:変わった構文
古い dev.to のチュートリアルからコードをコピーする場合、ストアはそのまま流用できますがコンポーネントはそうはいきません。Svelte 5 では export let は $props に置き換えられ、on:click は onclick 属性になり、<slot /> は snippet に置き換わりました。最も重要なのは、createEventDispatcher が非推奨になったことです。消去ボタンはイベントをディスパッチするのではなく、コールバック props(ondismiss?.())を呼び出すべきです。Svelte 4 版の Toast.svelte であれば、冒頭に export let type = 'info' と import { createEventDispatcher } があり、on:click={() => dispatch('dismiss')} を使っていたはずですが、これらはすべて Svelte 5 プロジェクトでは非推奨のパターンです。
バリアント、配置、アクセシビリティ
とりあえず動くトーストと、良質なトーストを分けるのは 3 つの UX 上の要素、すなわちバリアント、トランジション、スクリーンリーダー対応です。バリアントは単に type フィールドを背景色(info/success/error)にマッピングしたもの、svelte/transition の fade トランジションが表示・非表示をアニメーションさせ、高い z-index を持つ position: fixed のコンテナがトーストをページの上に固定します。
アクセシビリティは個別に検討する価値があります。トーストを読み上げさせる方法は 2 つあり、どちらか一方だけを選ぶべきです。個々のトーストに付ける role="alert" は aria-live="assertive" を暗黙的に意味し、ブラウザは実際に alert ノードを特別扱いします。MDN は、ページ読み込み後にノードが挿入された場合を含め、ほとんどのケースでその内容が読み上げられると述べています。ただし問題は、これがブラウザとスクリーンリーダーの組み合わせによって異なる点です。そのため、すでに DOM に存在している永続的なライブリージョンのほうが予測しやすい選択肢であり、上のコードでコンテナに aria-live="polite" を付け、トースト自体には role を付けていないのはそのためです。info と success には polite を使い、ユーザーの操作の後ろに読み上げがキューイングされるようにし、即座の注意が必要なエラーについてはコンテナ(あるいは別のリージョン)を assertive に切り替えましょう。
避けるべき間違いは、この 2 つを組み合わせることです。MDN は、aria-live と role="alert" を併用すると iOS の VoiceOver で二重読み上げが発生すると警告しており、polite なリージョンの内側に assertive な alert をレンダリングすると同様の重複読み上げを招きます。トースト実装のセッションリプレイでは、トーストが発火して自動的に消えたにもかかわらずまったく認識されなかったという失敗パターンが頻繁に見つかります。ライブリージョンがなかったために、何も読み上げられなかったのです。
ライブラリを使う:svelte-sonner
svelte-sonner は手軽に導入できる選択肢で、Svelte 5 向けに作られています。Emil Kowalski の Sonner を Svelte に移植したもので、同じ思想に基づくデフォルト設定を引き継いでいます。パッケージをインストールし、アプリのルート付近に <Toaster /> を 1 つマウントすれば、コードベースのどこから発火させたトーストもその中にレンダリングされます。
<script>
import { Toaster, toast } from 'svelte-sonner';
</script>
<Toaster richColors closeButton position="top-center" duration={5000} />
<button onclick={() => toast.success('Event has been created')}>Success</button>
<button onclick={() => toast.error('Event has not been created')}>Error</button>
依存関係を追加する見返りは toast.promise() です。これはローディング状態で表示され、promise が解決すると成功またはエラーのメッセージに自分自身を切り替えます。自前で実装するとなると本当に面倒なのは、まさにこのパターンです。
toast.promise(saveEvent(), {
loading: 'Saving…',
success: (data) => `${data.name} saved!`,
error: 'Could not save'
});
<Toaster /> は position、richColors、closeButton、duration といった props を受け取ります。Tailwind を使う場合は、unstyled: true と classes マップを含む toastOptions オブジェクトを渡して自分でトーストをスタイリングします。スワイプによる消去とキーボードフォーカス(⌥/alt + T)は標準で組み込まれています。npm i svelte-sonner は 1.x のビルドを解決します。プロジェクトのリリースノートの最新エントリは v1.1.1 で、期限切れしない設定のトーストが更新された瞬間に消えてしまうバグが修正されています。
代替候補は 2 つあります。svelte-french-toast は知っておく価値がありますが、公開されている安定版リリースは Svelte 4 時代のものなので、Svelte 5 ユーザーは svelte-hot-french-toast のようなフォークが必要です。もう 1 つは @zerodevx/svelte-toast で、現行の v0 系は Svelte 3、4、5 にまたがる peer dependencies を宣言しています。
自前実装 vs. svelte-sonner:どちらを選ぶか
依存ゼロ、マークアップの完全なコントロール、あるいは Svelte のストアを学びたいという場合は自前実装を選びましょう。promise トースト、スワイプでの消去、テーマ設定、アクセシビリティを最初から備えたものが欲しいなら svelte-sonner を使いましょう。
| 要件 | 自前実装 | svelte-sonner |
|---|---|---|
| 依存関係 | なし | パッケージ 1 つ |
| マークアップの制御 | 完全 | toastOptions 経由(unstyled + classes) |
| promise トースト | 自作が必要 | toast.promise() を標準搭載 |
| スワイプでの消去 | 自作 | 標準搭載 |
| アクセシビリティ | aria-live を自分で組み込む | 対応済み |
| Svelte 5 対応 | あり(runes/コールバック props を使えば) | ネイティブ対応 |
ライブラリの中では、svelte-sonner が Svelte 5 を直接ターゲットにしています。オリジナルの svelte-french-toast は Svelte 4 時代のもので、@zerodevx/svelte-toast の v0 系は Svelte 3、4、5 のいずれでも動作します。
必要なのが自動消去付きの success/error/info だけであれば、ストアベースの実装から始めてください。せいぜい 60 行程度で、ストアのパターンも学べます。promise 駆動のフィードバックやスワイプジェスチャーが必要になった時点で、svelte-sonner をインストールして自作コードを削除しましょう。どちらを選ぶにせよ、まず aria-live リージョンを組み込んでください。これは見落としやすく、しかも欠けていることに気づきにくい唯一のポイントです。
FAQ
Svelte 5 でも createEventDispatcher は使えますか?
動作はしますが Svelte 5 では非推奨です。そのため、これを使っている既存のコンポーネントは引き続き動作しますが、新しいコードで採用すべきではありません。トーストの消去のようなイベントを発行する場合の公式な代替手段はコールバック props で、たとえば ondismiss 関数を渡し、閉じるボタンから ondismiss?.() を呼び出します。Svelte のドキュメントでは、推奨される代替手段としてコールバック props と $host() rune が挙げられています。
各トーストに role='alert' を使うべきですか、それともコンテナに aria-live を使うべきですか?
どちらのアプローチでも機能しますが、両方ではなくどちらか一方を使ってください。ブラウザは role='alert' を特別に扱い、ページ読み込み後にノードが挿入された場合でもほとんどのケースでその内容を読み上げますが、これはブラウザとスクリーンリーダーの組み合わせによって異なります。すでに DOM に存在していて aria-live を持つ永続的なコンテナのほうが予測しやすい選択肢です。info と success には aria-live='polite'、エラーには 'assertive' を使います。両方を同時に行うと重複した読み上げのリスクがあり、MDN は aria-live と role='alert' を組み合わせると iOS の VoiceOver で二重読み上げが発生すると述べています。
Svelte 5 において svelte-sonner と svelte-french-toast の違いは何ですか?
svelte-sonner は Svelte 5 を直接ターゲットにしており、promise トースト、スワイプでの消去、richColors、閉じるボタンを備えた 1.x ビルドとしてインストールされます。公開されている安定版の svelte-french-toast は Svelte 4 時代のもので、その最後の安定版リリースは Svelte 5 より前のため、Svelte 5 ユーザーは svelte-hot-french-toast のようなフォークが必要です。svelte-french-toast 2.0.0-alpha は存在しますが、npm の安定版リリースとしては出荷されていません。
Svelte 5 でもトーストに writable ストアを使い続けられますか、それとも runes に切り替える必要がありますか?
writable ストアは Svelte 5 でも動作し非推奨ではないため、writable([]) に追加・消去のヘルパーを組み合わせた toasts ストアは完全に有効です。.svelte.ts ファイル内の runes は共有リアクティブ状態のためのより新しい慣用的なパターンですが、任意です。Svelte 5 の構文に移行する必要があるのはストア自体ではなく、そのストアを利用するコンポーネントのほうです。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k