トースト通知

成功・警告など種類別の通知を右下にスタック表示し、残り時間バーとともに自動消滅。操作後のフィードバック表示に使えます。

#css#javascript#animation

ライブデモ

使用例(お題: SaaS FlowDesk)

この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- FlowDesk:ダッシュボード操作のフィードバックをトーストで表示 -->
<div class="app">
  <header class="app__bar">
    <span class="app__logo">💼 FlowDesk</span>
    <span class="app__user">田中 健 ▾</span>
  </header>

  <main class="app__main">
    <h1 class="app__h1">プロジェクト設定</h1>
    <p class="app__lead">変更を加えると、画面右下に結果を通知します。</p>

    <div class="actions">
      <button class="btn" data-toast="success">変更を保存</button>
      <button class="btn" data-toast="info">メンバーを招待</button>
      <button class="btn" data-toast="warning">同期を再試行</button>
      <button class="btn" data-toast="error">プロジェクトを削除</button>
    </div>
  </main>

  <!-- トーストはここにスタックされる -->
  <div class="toasts" data-toasts aria-live="polite"></div>
</div>
CSS
/* FlowDesk SaaS テーマ */
:root{--navy:#0f1b34;--blue:#4f7cff;--ink:#1d2740;--line:#e3e8f2;--muted:#6b7794;--bg:#f4f6fb}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;font-family:"Segoe UI",system-ui,sans-serif;background:var(--bg);color:var(--ink)}
.app{max-width:560px;margin:0 auto;min-height:100vh;display:flex;flex-direction:column}
.app__bar{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;background:var(--navy);color:#fff}
.app__logo{font-weight:700}
.app__user{font-size:.82rem;color:#aab6d6}
.app__main{flex:1;padding:24px 22px}
.app__h1{margin:0 0 4px;font-size:1.35rem;color:var(--navy)}
.app__lead{margin:0 0 20px;font-size:.86rem;color:var(--muted)}
.actions{display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:360px}
.btn{appearance:none;cursor:pointer;font:inherit;font-weight:600;font-size:.86rem;padding:12px;border-radius:10px;background:#fff;border:1px solid var(--line);color:var(--ink);transition:border-color .2s,transform .1s}
.btn:hover{border-color:var(--blue)}
.btn:active{transform:scale(.98)}
/* トースト:右下にスタックし残り時間バーで自動消滅 */
.toasts{position:fixed;right:18px;bottom:18px;display:flex;flex-direction:column;gap:10px;z-index:9;width:280px;max-width:calc(100vw - 36px)}
.toast{
  position:relative;overflow:hidden;background:#fff;border-radius:12px;padding:13px 14px 14px 44px;
  box-shadow:0 12px 30px rgba(15,27,52,.18);border-left:4px solid var(--blue);
  animation:slide-in .3s cubic-bezier(.34,1.4,.6,1);
}
.toast.is-out{animation:slide-out .3s ease forwards}
.toast__icon{position:absolute;left:14px;top:13px;width:20px;height:20px;border-radius:50%;display:grid;place-items:center;color:#fff;font-size:.72rem;font-weight:800}
.toast__title{margin:0;font-size:.85rem;font-weight:700}
.toast__msg{margin:2px 0 0;font-size:.76rem;color:var(--muted);line-height:1.5}
.toast__bar{position:absolute;left:0;bottom:0;height:3px;background:currentColor;opacity:.5;animation:shrink var(--life,3500ms) linear forwards}
.toast.success{border-color:#22b07d;color:#22b07d}
.toast.info{border-color:var(--blue);color:var(--blue)}
.toast.warning{border-color:#e0a92a;color:#e0a92a}
.toast.error{border-color:#e2574c;color:#e2574c}
.toast.success .toast__icon{background:#22b07d}
.toast.info .toast__icon{background:var(--blue)}
.toast.warning .toast__icon{background:#e0a92a}
.toast.error .toast__icon{background:#e2574c}
.toast__title,.toast__msg{color:var(--ink)}
@keyframes slide-in{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:none}}
@keyframes slide-out{to{opacity:0;transform:translateX(40px)}}
@keyframes shrink{from{width:100%}to{width:0}}
@media (prefers-reduced-motion:reduce){.toast,.toast.is-out,.toast__bar{animation:none}}
JavaScript
// 種類別トーストを右下にスタック表示し、一定時間で自動消滅
const stack = document.querySelector('[data-toasts]');
const LIFE = 3500;

// 種類ごとの文言とアイコン
const PRESET = {
  success: { icon: '✓', title: '保存しました', msg: 'プロジェクト設定を更新しました。' },
  info:    { icon: 'i', title: '招待を送信', msg: '田中さんへ招待メールを送りました。' },
  warning: { icon: '!', title: '同期を再試行中', msg: '接続が不安定です。再接続しています。' },
  error:   { icon: '×', title: '削除に失敗', msg: '権限が不足しています。管理者に確認してください。' },
};

function showToast(type) {
  if (!stack) return;
  const p = PRESET[type] || PRESET.info;
  const el = document.createElement('div');
  el.className = `toast ${type}`;
  el.style.setProperty('--life', LIFE + 'ms');
  el.innerHTML =
    `<span class="toast__icon">${p.icon}</span>` +
    `<p class="toast__title">${p.title}</p>` +
    `<p class="toast__msg">${p.msg}</p>` +
    `<span class="toast__bar"></span>`;
  stack.appendChild(el);

  // 残り時間経過でスライドアウト→除去
  const timer = setTimeout(() => dismiss(el), LIFE);
  el.addEventListener('click', () => { clearTimeout(timer); dismiss(el); });
}

function dismiss(el) {
  el.classList.add('is-out');
  el.addEventListener('animationend', () => el.remove(), { once: true });
}

document.querySelectorAll('[data-toast]').forEach((btn) => {
  btn.addEventListener('click', () => showToast(btn.dataset.toast));
});

コード

HTML
<!-- トースト:ボタンで種類別の通知をスタック表示、自動で消える -->
<div class="panel">
  <h2 class="panel__title">トースト通知</h2>
  <p class="panel__hint">ボタンを押すと右下に通知が積み重なります。</p>
  <div class="panel__btns">
    <button class="tbtn tbtn--ok"   data-type="success">成功</button>
    <button class="tbtn tbtn--info" data-type="info">お知らせ</button>
    <button class="tbtn tbtn--warn" data-type="warn">警告</button>
    <button class="tbtn tbtn--err"  data-type="error">エラー</button>
  </div>
</div>

<!-- 通知はここに動的に追加 -->
<div class="toasts" id="toasts" aria-live="polite" aria-atomic="false"></div>
CSS
:root{
  --bg:#0d1117;
  --card:#161b22;
  --text:#e6edf3;
  --muted:#8b949e;
  --ok:#22c55e;
  --info:#3b82f6;
  --warn:#f59e0b;
  --err:#ef4444;
}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;position:relative;overflow:hidden;
  display:grid;place-items:center;padding:24px;
  font-family:"Segoe UI",system-ui,sans-serif;color:var(--text);
  background:radial-gradient(800px 400px at 50% -10%,#1b2436,transparent),var(--bg);
}
.panel{
  width:min(420px,100%);text-align:center;
  background:var(--card);border:1px solid #21262d;border-radius:18px;
  padding:26px 22px;
}
.panel__title{margin:0 0 6px;font-size:1.2rem}
.panel__hint{margin:0 0 20px;color:var(--muted);font-size:.88rem}
.panel__btns{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.tbtn{
  font:inherit;font-weight:600;cursor:pointer;color:#fff;
  border:none;border-radius:11px;padding:12px;
  transition:transform .15s,filter .2s;
}
.tbtn:hover{filter:brightness(1.1)}
.tbtn:active{transform:translateY(1px)}
.tbtn--ok{background:var(--ok)}
.tbtn--info{background:var(--info)}
.tbtn--warn{background:var(--warn);color:#3a2a00}
.tbtn--err{background:var(--err)}

/* スタック容器(右下) */
.toasts{
  position:fixed;right:18px;bottom:18px;z-index:50;
  display:flex;flex-direction:column;gap:10px;width:min(300px,80vw);
}
.toast{
  display:flex;align-items:flex-start;gap:10px;
  background:var(--card);border:1px solid #2a313b;border-left:4px solid var(--c);
  border-radius:12px;padding:12px 14px;color:var(--text);
  box-shadow:0 16px 30px -16px rgba(0,0,0,.7);
  animation:slideIn .35s cubic-bezier(.2,.9,.3,1.2);
  position:relative;overflow:hidden;
}
.toast.is-leaving{animation:slideOut .3s ease forwards}
.toast__icon{
  flex:none;width:22px;height:22px;border-radius:50%;
  display:grid;place-items:center;font-size:.8rem;font-weight:700;
  color:#fff;background:var(--c);
}
.toast__body{flex:1;min-width:0}
.toast__title{font-weight:700;font-size:.9rem}
.toast__msg{color:var(--muted);font-size:.8rem;margin-top:2px}
.toast__close{
  background:none;border:none;color:var(--muted);cursor:pointer;
  font-size:16px;line-height:1;padding:0 2px;
}
.toast__close:hover{color:var(--text)}
/* 残り時間バー */
.toast__bar{
  position:absolute;left:0;bottom:0;height:3px;width:100%;
  background:var(--c);transform-origin:left;
  animation:shrink var(--dur,4s) linear forwards;
}
@keyframes slideIn{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:none}}
@keyframes slideOut{to{opacity:0;transform:translateX(40px)}}
@keyframes shrink{from{transform:scaleX(1)}to{transform:scaleX(0)}}
@media (prefers-reduced-motion:reduce){
  .toast,.toast.is-leaving,.toast__bar{animation:none}
}
JavaScript
// トーストを動的生成して自動消滅させる
const host = document.getElementById('toasts');

// 種類ごとの見た目・文言
const PRESET = {
  success: { c: 'var(--ok)',   icon: '✓', title: '成功しました',   msg: '変更内容を保存しました。' },
  info:    { c: 'var(--info)', icon: 'i', title: 'お知らせ',       msg: '新しいバージョンが利用できます。' },
  warn:    { c: 'var(--warn)', icon: '!', title: 'ご注意ください', msg: '空き容量が少なくなっています。' },
  error:   { c: 'var(--err)',  icon: '×', title: 'エラー',         msg: '通信に失敗しました。再試行してください。' },
};
const DURATION = 4000; // 表示時間(ms)

const removeToast = (el) => {
  if (!el || el.classList.contains('is-leaving')) return;
  el.classList.add('is-leaving');
  el.addEventListener('animationend', () => el.remove(), { once: true });
  // 念のためのフォールバック
  setTimeout(() => el.remove(), 400);
};

const showToast = (type) => {
  if (!host) return;
  const p = PRESET[type] || PRESET.info;

  const el = document.createElement('div');
  el.className = 'toast';
  el.style.setProperty('--c', p.c);
  el.style.setProperty('--dur', DURATION + 'ms');
  el.innerHTML = `
    <span class="toast__icon">${p.icon}</span>
    <div class="toast__body">
      <div class="toast__title">${p.title}</div>
      <div class="toast__msg">${p.msg}</div>
    </div>
    <button class="toast__close" aria-label="閉じる">&times;</button>
    <span class="toast__bar"></span>`;

  el.querySelector('.toast__close').addEventListener('click', () => removeToast(el));
  host.prepend(el);

  // 古い通知が溜まりすぎないよう上限
  while (host.children.length > 5) removeToast(host.lastElementChild);

  setTimeout(() => removeToast(el), DURATION);
};

document.querySelectorAll('[data-type]').forEach((btn) => {
  btn.addEventListener('click', () => showToast(btn.dataset.type));
});

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「トースト通知」の効果を追加してください。

# 追加してほしい効果
トースト通知(UIコンポーネント)
成功・警告など種類別の通知を右下にスタック表示し、残り時間バーとともに自動消滅。操作後のフィードバック表示に使えます。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- トースト:ボタンで種類別の通知をスタック表示、自動で消える -->
<div class="panel">
  <h2 class="panel__title">トースト通知</h2>
  <p class="panel__hint">ボタンを押すと右下に通知が積み重なります。</p>
  <div class="panel__btns">
    <button class="tbtn tbtn--ok"   data-type="success">成功</button>
    <button class="tbtn tbtn--info" data-type="info">お知らせ</button>
    <button class="tbtn tbtn--warn" data-type="warn">警告</button>
    <button class="tbtn tbtn--err"  data-type="error">エラー</button>
  </div>
</div>

<!-- 通知はここに動的に追加 -->
<div class="toasts" id="toasts" aria-live="polite" aria-atomic="false"></div>

【CSS】
:root{
  --bg:#0d1117;
  --card:#161b22;
  --text:#e6edf3;
  --muted:#8b949e;
  --ok:#22c55e;
  --info:#3b82f6;
  --warn:#f59e0b;
  --err:#ef4444;
}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;position:relative;overflow:hidden;
  display:grid;place-items:center;padding:24px;
  font-family:"Segoe UI",system-ui,sans-serif;color:var(--text);
  background:radial-gradient(800px 400px at 50% -10%,#1b2436,transparent),var(--bg);
}
.panel{
  width:min(420px,100%);text-align:center;
  background:var(--card);border:1px solid #21262d;border-radius:18px;
  padding:26px 22px;
}
.panel__title{margin:0 0 6px;font-size:1.2rem}
.panel__hint{margin:0 0 20px;color:var(--muted);font-size:.88rem}
.panel__btns{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.tbtn{
  font:inherit;font-weight:600;cursor:pointer;color:#fff;
  border:none;border-radius:11px;padding:12px;
  transition:transform .15s,filter .2s;
}
.tbtn:hover{filter:brightness(1.1)}
.tbtn:active{transform:translateY(1px)}
.tbtn--ok{background:var(--ok)}
.tbtn--info{background:var(--info)}
.tbtn--warn{background:var(--warn);color:#3a2a00}
.tbtn--err{background:var(--err)}

/* スタック容器(右下) */
.toasts{
  position:fixed;right:18px;bottom:18px;z-index:50;
  display:flex;flex-direction:column;gap:10px;width:min(300px,80vw);
}
.toast{
  display:flex;align-items:flex-start;gap:10px;
  background:var(--card);border:1px solid #2a313b;border-left:4px solid var(--c);
  border-radius:12px;padding:12px 14px;color:var(--text);
  box-shadow:0 16px 30px -16px rgba(0,0,0,.7);
  animation:slideIn .35s cubic-bezier(.2,.9,.3,1.2);
  position:relative;overflow:hidden;
}
.toast.is-leaving{animation:slideOut .3s ease forwards}
.toast__icon{
  flex:none;width:22px;height:22px;border-radius:50%;
  display:grid;place-items:center;font-size:.8rem;font-weight:700;
  color:#fff;background:var(--c);
}
.toast__body{flex:1;min-width:0}
.toast__title{font-weight:700;font-size:.9rem}
.toast__msg{color:var(--muted);font-size:.8rem;margin-top:2px}
.toast__close{
  background:none;border:none;color:var(--muted);cursor:pointer;
  font-size:16px;line-height:1;padding:0 2px;
}
.toast__close:hover{color:var(--text)}
/* 残り時間バー */
.toast__bar{
  position:absolute;left:0;bottom:0;height:3px;width:100%;
  background:var(--c);transform-origin:left;
  animation:shrink var(--dur,4s) linear forwards;
}
@keyframes slideIn{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:none}}
@keyframes slideOut{to{opacity:0;transform:translateX(40px)}}
@keyframes shrink{from{transform:scaleX(1)}to{transform:scaleX(0)}}
@media (prefers-reduced-motion:reduce){
  .toast,.toast.is-leaving,.toast__bar{animation:none}
}

【JavaScript】
// トーストを動的生成して自動消滅させる
const host = document.getElementById('toasts');

// 種類ごとの見た目・文言
const PRESET = {
  success: { c: 'var(--ok)',   icon: '✓', title: '成功しました',   msg: '変更内容を保存しました。' },
  info:    { c: 'var(--info)', icon: 'i', title: 'お知らせ',       msg: '新しいバージョンが利用できます。' },
  warn:    { c: 'var(--warn)', icon: '!', title: 'ご注意ください', msg: '空き容量が少なくなっています。' },
  error:   { c: 'var(--err)',  icon: '×', title: 'エラー',         msg: '通信に失敗しました。再試行してください。' },
};
const DURATION = 4000; // 表示時間(ms)

const removeToast = (el) => {
  if (!el || el.classList.contains('is-leaving')) return;
  el.classList.add('is-leaving');
  el.addEventListener('animationend', () => el.remove(), { once: true });
  // 念のためのフォールバック
  setTimeout(() => el.remove(), 400);
};

const showToast = (type) => {
  if (!host) return;
  const p = PRESET[type] || PRESET.info;

  const el = document.createElement('div');
  el.className = 'toast';
  el.style.setProperty('--c', p.c);
  el.style.setProperty('--dur', DURATION + 'ms');
  el.innerHTML = `
    <span class="toast__icon">${p.icon}</span>
    <div class="toast__body">
      <div class="toast__title">${p.title}</div>
      <div class="toast__msg">${p.msg}</div>
    </div>
    <button class="toast__close" aria-label="閉じる">&times;</button>
    <span class="toast__bar"></span>`;

  el.querySelector('.toast__close').addEventListener('click', () => removeToast(el));
  host.prepend(el);

  // 古い通知が溜まりすぎないよう上限
  while (host.children.length > 5) removeToast(host.lastElementChild);

  setTimeout(() => removeToast(el), DURATION);
};

document.querySelectorAll('[data-type]').forEach((btn) => {
  btn.addEventListener('click', () => showToast(btn.dataset.type));
});

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。