スナックバー
操作の直後に画面下部へ一時表示されるスナックバー。「元に戻す」アクションと自動消滅の進捗バーを備え、連続操作で通知が積み重なります。
ライブデモ
コード
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="閉じる">×</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="閉じる">×</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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。