スナックバー

操作の直後に画面下部へ一時表示されるスナックバー。「元に戻す」アクションと自動消滅の進捗バーを備え、連続操作で通知が積み重なります。

#css#js#alert#toast

ライブデモ

コード

HTML
<!-- スナックバー: 操作後に画面下部へ一時表示。「元に戻す」アクション付きで数秒後に自動消滅し、
     連続操作すると積み重なって表示される。 -->
<div class="sb-stage">
  <h2 class="sb-heading">受信トレイ</h2>
  <ul class="sb-list">
    <li class="sb-item">
      <span class="sb-item__label">プロジェクト提案書.pdf</span>
      <button type="button" class="sb-item__btn" data-action="delete">削除</button>
    </li>
    <li class="sb-item">
      <span class="sb-item__label">今週のニュースレター</span>
      <button type="button" class="sb-item__btn" data-action="spam">迷惑メール</button>
    </li>
    <li class="sb-item">
      <span class="sb-item__label">会議の議事録</span>
      <button type="button" class="sb-item__btn" data-action="archive">アーカイブ</button>
    </li>
  </ul>
  <p class="sb-hint">ボタンを押すと画面下部にスナックバーが表示されます(連続で押すと積み重なります)。</p>
</div>

<div class="sb-host" id="sbHost" aria-live="polite" aria-atomic="false"></div>
CSS
/* スナックバー: 画面下部中央に一時表示。積み重ね + 元に戻す導線 + 自動消滅バー */
* { box-sizing: border-box; }
:root{
  --sb-ink: #1f2430;
  --sb-muted: #6b7280;
  --sb-accent: #16a34a;
  --sb-dark: #22262f;
}
body{
  margin: 0;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding: 64px 24px;
  font-family: "Segoe UI", system-ui, -apple-system, "Hiragino Sans", sans-serif;
  background:
    radial-gradient(900px 420px at 50% -10%, #eef1fb, transparent 60%),
    #f5f6fa;
  color: var(--sb-ink);
}

.sb-stage{ width: min(420px, 100%); }
.sb-heading{ margin: 0 0 14px; font-size: 1.05rem; font-weight: 700; }
.sb-list{ list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.sb-item{
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 12px 14px;
  background: #fff;
  border: 1px solid #e6e8f0;
  border-radius: 10px;
  font-size: .88rem;
}
.sb-item__label{ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-item__btn{
  flex: none;
  font: inherit;
  font-size: .8rem;
  font-weight: 600;
  padding: 7px 14px;
  border-radius: 999px;
  border: 1px solid #dfe2ee;
  background: #f7f8fc;
  color: var(--sb-ink);
  cursor: pointer;
  transition: background .2s ease, transform .15s ease;
}
.sb-item__btn:hover{ background: #eef0f7; }
.sb-item__btn:active{ transform: translateY(1px); }
/* 待機中も「削除」だけ控えめに注意を引く */
.sb-item__btn[data-action="delete"]{ animation: sbHintPulse 4.5s ease-in-out infinite; }
@keyframes sbHintPulse{
  0%, 100%{ box-shadow: 0 0 0 0 rgba(220, 38, 38, 0); }
  50%{ box-shadow: 0 0 0 4px rgba(220, 38, 38, .12); }
}
.sb-hint{ margin: 0; font-size: .78rem; color: var(--sb-muted); }

.sb-host{
  position: fixed;
  left: 50%; bottom: 22px;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: min(380px, 92vw);
  z-index: 50;
}
.snackbar{
  position: relative;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 13px 14px 13px 16px;
  background: var(--sb-dark);
  color: #f2f3f7;
  border-radius: 10px;
  box-shadow: 0 18px 40px -18px rgba(0, 0, 0, .55);
  overflow: hidden;
  animation: sbIn .32s cubic-bezier(.2,.9,.3,1.2);
}
.snackbar.is-leaving{ animation: sbOut .28s ease forwards; }
.snackbar__msg{ flex: 1; margin: 0; font-size: .86rem; line-height: 1.4; }
.snackbar__undo{
  flex: none;
  font: inherit;
  font-weight: 700;
  font-size: .82rem;
  background: none;
  border: none;
  cursor: pointer;
  color: #8ab4ff;
  padding: 4px 2px;
  letter-spacing: .02em;
}
.snackbar__undo:hover{ text-decoration: underline; }
.snackbar__undo:focus-visible{ outline: 2px solid #8ab4ff; outline-offset: 2px; }
.snackbar__close{
  flex: none;
  width: 22px; height: 22px;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, .08);
  color: #c7cad6;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  display: grid;
  place-items: center;
}
.snackbar__close:hover{ background: rgba(255, 255, 255, .16); color: #fff; }
.snackbar__bar{
  position: absolute;
  left: 0; bottom: 0;
  height: 3px; width: 100%;
  background: var(--bar, var(--sb-accent));
  transform-origin: left;
  animation: sbShrink var(--dur, 4500ms) linear forwards;
}
@keyframes sbIn{ from{ opacity: 0; transform: translateY(16px) scale(.98); } to{ opacity: 1; transform: none; } }
@keyframes sbOut{ to{ opacity: 0; transform: translateY(10px) scale(.98); } }
@keyframes sbShrink{ from{ transform: scaleX(1); } to{ transform: scaleX(0); } }

@media (prefers-reduced-motion: reduce){
  .snackbar, .snackbar.is-leaving, .snackbar__bar, .sb-item__btn[data-action="delete"]{ animation: none; }
}
JavaScript
// スナックバー: 操作ボタンで下部に一時表示。元に戻す/自動消滅/複数積み重ねに対応
(() => {
  const host = document.getElementById('sbHost');
  if (!host) return; // null安全

  const DURATION = 4500; // 自動で消えるまでの時間(ms)
  const MAX_STACK = 3; // 同時に積み重なる上限
  const LABEL = {
    delete: 'メールをゴミ箱に移動しました',
    spam: '迷惑メールに移動しました',
    archive: 'アーカイブに移動しました',
  };

  const remove = (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 show = (message) => {
    const el = document.createElement('div');
    el.className = 'snackbar';
    el.setAttribute('role', 'status');
    el.innerHTML = `
      <p class="snackbar__msg"></p>
      <button type="button" class="snackbar__undo">元に戻す</button>
      <button type="button" class="snackbar__close" aria-label="閉じる">&times;</button>
      <span class="snackbar__bar" style="--dur:${DURATION}ms"></span>`;
    el.querySelector('.snackbar__msg').textContent = message;

    const timer = setTimeout(() => remove(el), DURATION);

    el.querySelector('.snackbar__undo').addEventListener('click', () => {
      clearTimeout(timer);
      const msg = el.querySelector('.snackbar__msg');
      const undo = el.querySelector('.snackbar__undo');
      const bar = el.querySelector('.snackbar__bar');
      if (msg) msg.textContent = '操作を元に戻しました';
      if (undo) undo.remove();
      if (bar) bar.remove();
      setTimeout(() => remove(el), 1200);
    });

    el.querySelector('.snackbar__close').addEventListener('click', () => {
      clearTimeout(timer);
      remove(el);
    });

    host.appendChild(el);
    while (host.children.length > MAX_STACK) remove(host.firstElementChild);
  };

  document.querySelectorAll('[data-action]').forEach((btn) => {
    btn.addEventListener('click', () => {
      show(LABEL[btn.dataset.action] || '操作しました');
    });
  });
})();

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

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

# 追加してほしい効果
スナックバー(アラート & 空状態)
操作の直後に画面下部へ一時表示されるスナックバー。「元に戻す」アクションと自動消滅の進捗バーを備え、連続操作で通知が積み重なります。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- スナックバー: 操作後に画面下部へ一時表示。「元に戻す」アクション付きで数秒後に自動消滅し、
     連続操作すると積み重なって表示される。 -->
<div class="sb-stage">
  <h2 class="sb-heading">受信トレイ</h2>
  <ul class="sb-list">
    <li class="sb-item">
      <span class="sb-item__label">プロジェクト提案書.pdf</span>
      <button type="button" class="sb-item__btn" data-action="delete">削除</button>
    </li>
    <li class="sb-item">
      <span class="sb-item__label">今週のニュースレター</span>
      <button type="button" class="sb-item__btn" data-action="spam">迷惑メール</button>
    </li>
    <li class="sb-item">
      <span class="sb-item__label">会議の議事録</span>
      <button type="button" class="sb-item__btn" data-action="archive">アーカイブ</button>
    </li>
  </ul>
  <p class="sb-hint">ボタンを押すと画面下部にスナックバーが表示されます(連続で押すと積み重なります)。</p>
</div>

<div class="sb-host" id="sbHost" aria-live="polite" aria-atomic="false"></div>

【CSS】
/* スナックバー: 画面下部中央に一時表示。積み重ね + 元に戻す導線 + 自動消滅バー */
* { box-sizing: border-box; }
:root{
  --sb-ink: #1f2430;
  --sb-muted: #6b7280;
  --sb-accent: #16a34a;
  --sb-dark: #22262f;
}
body{
  margin: 0;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding: 64px 24px;
  font-family: "Segoe UI", system-ui, -apple-system, "Hiragino Sans", sans-serif;
  background:
    radial-gradient(900px 420px at 50% -10%, #eef1fb, transparent 60%),
    #f5f6fa;
  color: var(--sb-ink);
}

.sb-stage{ width: min(420px, 100%); }
.sb-heading{ margin: 0 0 14px; font-size: 1.05rem; font-weight: 700; }
.sb-list{ list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.sb-item{
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 12px 14px;
  background: #fff;
  border: 1px solid #e6e8f0;
  border-radius: 10px;
  font-size: .88rem;
}
.sb-item__label{ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-item__btn{
  flex: none;
  font: inherit;
  font-size: .8rem;
  font-weight: 600;
  padding: 7px 14px;
  border-radius: 999px;
  border: 1px solid #dfe2ee;
  background: #f7f8fc;
  color: var(--sb-ink);
  cursor: pointer;
  transition: background .2s ease, transform .15s ease;
}
.sb-item__btn:hover{ background: #eef0f7; }
.sb-item__btn:active{ transform: translateY(1px); }
/* 待機中も「削除」だけ控えめに注意を引く */
.sb-item__btn[data-action="delete"]{ animation: sbHintPulse 4.5s ease-in-out infinite; }
@keyframes sbHintPulse{
  0%, 100%{ box-shadow: 0 0 0 0 rgba(220, 38, 38, 0); }
  50%{ box-shadow: 0 0 0 4px rgba(220, 38, 38, .12); }
}
.sb-hint{ margin: 0; font-size: .78rem; color: var(--sb-muted); }

.sb-host{
  position: fixed;
  left: 50%; bottom: 22px;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: min(380px, 92vw);
  z-index: 50;
}
.snackbar{
  position: relative;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 13px 14px 13px 16px;
  background: var(--sb-dark);
  color: #f2f3f7;
  border-radius: 10px;
  box-shadow: 0 18px 40px -18px rgba(0, 0, 0, .55);
  overflow: hidden;
  animation: sbIn .32s cubic-bezier(.2,.9,.3,1.2);
}
.snackbar.is-leaving{ animation: sbOut .28s ease forwards; }
.snackbar__msg{ flex: 1; margin: 0; font-size: .86rem; line-height: 1.4; }
.snackbar__undo{
  flex: none;
  font: inherit;
  font-weight: 700;
  font-size: .82rem;
  background: none;
  border: none;
  cursor: pointer;
  color: #8ab4ff;
  padding: 4px 2px;
  letter-spacing: .02em;
}
.snackbar__undo:hover{ text-decoration: underline; }
.snackbar__undo:focus-visible{ outline: 2px solid #8ab4ff; outline-offset: 2px; }
.snackbar__close{
  flex: none;
  width: 22px; height: 22px;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, .08);
  color: #c7cad6;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  display: grid;
  place-items: center;
}
.snackbar__close:hover{ background: rgba(255, 255, 255, .16); color: #fff; }
.snackbar__bar{
  position: absolute;
  left: 0; bottom: 0;
  height: 3px; width: 100%;
  background: var(--bar, var(--sb-accent));
  transform-origin: left;
  animation: sbShrink var(--dur, 4500ms) linear forwards;
}
@keyframes sbIn{ from{ opacity: 0; transform: translateY(16px) scale(.98); } to{ opacity: 1; transform: none; } }
@keyframes sbOut{ to{ opacity: 0; transform: translateY(10px) scale(.98); } }
@keyframes sbShrink{ from{ transform: scaleX(1); } to{ transform: scaleX(0); } }

@media (prefers-reduced-motion: reduce){
  .snackbar, .snackbar.is-leaving, .snackbar__bar, .sb-item__btn[data-action="delete"]{ animation: none; }
}

【JavaScript】
// スナックバー: 操作ボタンで下部に一時表示。元に戻す/自動消滅/複数積み重ねに対応
(() => {
  const host = document.getElementById('sbHost');
  if (!host) return; // null安全

  const DURATION = 4500; // 自動で消えるまでの時間(ms)
  const MAX_STACK = 3; // 同時に積み重なる上限
  const LABEL = {
    delete: 'メールをゴミ箱に移動しました',
    spam: '迷惑メールに移動しました',
    archive: 'アーカイブに移動しました',
  };

  const remove = (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 show = (message) => {
    const el = document.createElement('div');
    el.className = 'snackbar';
    el.setAttribute('role', 'status');
    el.innerHTML = `
      <p class="snackbar__msg"></p>
      <button type="button" class="snackbar__undo">元に戻す</button>
      <button type="button" class="snackbar__close" aria-label="閉じる">&times;</button>
      <span class="snackbar__bar" style="--dur:${DURATION}ms"></span>`;
    el.querySelector('.snackbar__msg').textContent = message;

    const timer = setTimeout(() => remove(el), DURATION);

    el.querySelector('.snackbar__undo').addEventListener('click', () => {
      clearTimeout(timer);
      const msg = el.querySelector('.snackbar__msg');
      const undo = el.querySelector('.snackbar__undo');
      const bar = el.querySelector('.snackbar__bar');
      if (msg) msg.textContent = '操作を元に戻しました';
      if (undo) undo.remove();
      if (bar) bar.remove();
      setTimeout(() => remove(el), 1200);
    });

    el.querySelector('.snackbar__close').addEventListener('click', () => {
      clearTimeout(timer);
      remove(el);
    });

    host.appendChild(el);
    while (host.children.length > MAX_STACK) remove(host.firstElementChild);
  };

  document.querySelectorAll('[data-action]').forEach((btn) => {
    btn.addEventListener('click', () => {
      show(LABEL[btn.dataset.action] || '操作しました');
    });
  });
})();

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

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