通知パネル
ベルアイコンと未読数バッジを備えた通知パネル。クリックで開く一覧は種別ごとに色分けされ、クリックで既読化、新着も自動で届きます。
ライブデモ
コード
HTML
<!-- 通知パネル: ベルアイコン+未読数バッジ。クリックでドロップダウンを開閉し、
クリックで既読化、しばらくすると新着通知が自動で届く(ベルが小さく揺れる)。 -->
<div class="np-stage">
<div class="np-topbar">
<span class="np-topbar__brand">Dashboard</span>
<div class="np-bell-wrap">
<button
type="button"
class="np-bell"
id="npBell"
aria-haspopup="true"
aria-expanded="false"
aria-controls="npPanel"
aria-label="通知を開く"
>
<svg class="np-bell__svg" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2a6 6 0 0 0-6 6v3.2c0 .53-.21 1.04-.59 1.41L4 14v1h16v-1l-1.41-1.39a2 2 0 0 1-.59-1.41V8a6 6 0 0 0-6-6z" fill="currentColor"/>
<path d="M9.5 18a2.5 2.5 0 0 0 5 0h-5z" fill="currentColor"/>
</svg>
<span class="np-bell__badge" id="npBadge" data-zero="false">0</span>
</button>
<div class="np-panel" id="npPanel" role="region" aria-label="通知一覧" hidden>
<div class="np-panel__head">
<span>通知</span>
<button type="button" class="np-panel__markall" id="npMarkAll">すべて既読にする</button>
</div>
<ul class="np-list" id="npList"></ul>
</div>
</div>
</div>
<div class="np-page">
<h1>ようこそ</h1>
<p>ベルアイコンをクリックすると通知の一覧が開きます。しばらくすると新着が届きます。</p>
</div>
<!-- 新着通知は視覚的な一覧とは別に、ここでスクリーンリーダーへ簡潔に読み上げる -->
<div class="np-sr-only" id="npAnnounce" aria-live="polite" aria-atomic="true"></div>
</div>
CSS
/* 通知パネル: ベル+未読バッジ、ドロップダウン内は種別ごとに色分けしたドットで意味を示す */
* { box-sizing: border-box; }
:root{
--np-ink: #1f2430;
--np-muted: #6b7280;
--np-success: #16a34a;
--np-info: #2563eb;
--np-warning: #b45309;
--np-error: #dc2626;
}
body{
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", system-ui, -apple-system, "Hiragino Sans", sans-serif;
color: var(--np-ink);
background: #f4f5fa;
}
.np-stage{ min-height: 100vh; display: flex; flex-direction: column; }
.np-topbar{
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 22px;
background: #fff;
border-bottom: 1px solid #e9eaf2;
}
.np-topbar__brand{ font-weight: 700; }
.np-bell-wrap{ position: relative; }
.np-bell{
position: relative;
width: 40px; height: 40px;
display: grid;
place-items: center;
border-radius: 50%;
border: none;
background: #f0f1f6;
color: #454b5c;
cursor: pointer;
transition: background .2s ease;
}
.np-bell:hover{ background: #e5e7f0; }
.np-bell:focus-visible{ outline: 2px solid var(--np-info); outline-offset: 2px; }
.np-bell__svg{ width: 20px; height: 20px; }
/* 新着が届いた瞬間にだけ小さく揺れる(待機中の演出は自動着信のたびに発生) */
.np-bell.is-ringing{ animation: npRing .6s ease; }
@keyframes npRing{
0%, 100%{ transform: rotate(0); }
20%{ transform: rotate(-14deg); }
40%{ transform: rotate(11deg); }
60%{ transform: rotate(-7deg); }
80%{ transform: rotate(4deg); }
}
.np-bell__badge{
position: absolute;
top: -2px; right: -2px;
min-width: 18px; height: 18px;
padding: 0 4px;
border-radius: 999px;
background: var(--np-error);
color: #fff;
font-size: .68rem;
font-weight: 700;
display: grid;
place-items: center;
border: 2px solid #fff;
}
.np-bell__badge[data-zero="true"]{ display: none; }
.np-bell__badge.is-bump{ animation: npBump .35s ease; }
@keyframes npBump{
0%{ transform: scale(1); }
40%{ transform: scale(1.35); }
100%{ transform: scale(1); }
}
.np-panel{
position: absolute;
top: calc(100% + 10px);
right: 0;
width: min(340px, 88vw);
background: #fff;
border: 1px solid #e9eaf2;
border-radius: 14px;
box-shadow: 0 24px 50px -20px rgba(20, 24, 40, .25);
overflow: hidden;
animation: npOpen .22s cubic-bezier(.2,.9,.3,1.2);
z-index: 70;
}
.np-panel[hidden]{ display: none; }
@keyframes npOpen{
from{ opacity: 0; transform: translateY(-6px) scale(.98); }
to{ opacity: 1; transform: none; }
}
.np-panel__head{
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid #eef0f6;
font-size: .85rem;
font-weight: 700;
}
.np-panel__markall{
font: inherit;
font-size: .76rem;
font-weight: 600;
border: none;
background: none;
color: var(--np-info);
cursor: pointer;
}
.np-panel__markall:hover{ text-decoration: underline; }
.np-panel__markall:focus-visible{ outline: 2px solid var(--np-info); outline-offset: 2px; }
.np-list{ list-style: none; margin: 0; padding: 0; max-height: 320px; overflow-y: auto; }
.np-item{
display: flex;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid #f2f3f8;
cursor: pointer;
background: #fff;
transition: background .15s ease;
}
.np-item:last-child{ border-bottom: none; }
.np-item:hover{ background: #f7f8fc; }
.np-item:focus-visible{ outline: 2px solid var(--np-info); outline-offset: -2px; }
.np-item__dot{
flex: none;
width: 9px; height: 9px;
margin-top: 6px;
border-radius: 50%;
background: var(--c);
}
.np-item:not(.is-unread) .np-item__dot{ background: #d5d8e2; }
.np-item__body{ flex: 1; min-width: 0; }
.np-item__msg{ margin: 0; font-size: .84rem; line-height: 1.5; color: var(--np-ink); }
.np-item.is-unread .np-item__msg{ font-weight: 700; }
.np-item__time{ margin: 2px 0 0; font-size: .72rem; color: var(--np-muted); }
.np-empty{ padding: 26px 16px; text-align: center; font-size: .82rem; color: var(--np-muted); }
.np-page{
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
padding: 60px 24px;
}
.np-page h1{ margin: 0; font-size: 1.3rem; }
.np-page p{ margin: 0; color: var(--np-muted); max-width: 360px; font-size: .88rem; }
/* スクリーンリーダー専用の告知領域(視覚的には非表示) */
.np-sr-only{
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0; border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce){
.np-bell.is-ringing, .np-bell__badge.is-bump, .np-panel{ animation: none; }
}
JavaScript
// 通知パネル: ベル+未読バッジ、開閉/既読化/新着の自動着信を管理する
(() => {
const bell = document.getElementById('npBell');
const badge = document.getElementById('npBadge');
const panel = document.getElementById('npPanel');
const list = document.getElementById('npList');
const markAllBtn = document.getElementById('npMarkAll');
const announce = document.getElementById('npAnnounce');
if (!bell || !badge || !panel || !list) return; // null安全
const TYPE_LABEL = { success: '成功', info: 'お知らせ', warning: '警告', error: 'エラー' };
let seq = 0;
const nextId = () => 'n' + (seq += 1);
const items = [
{ id: nextId(), type: 'error', msg: '支払いに失敗しました。カード情報をご確認ください。', time: '3分前', unread: true },
{ id: nextId(), type: 'warning', msg: 'ストレージの残り容量が少なくなっています。', time: '25分前', unread: true },
{ id: nextId(), type: 'success', msg: 'レポートの生成が完了しました。', time: '1時間前', unread: true },
{ id: nextId(), type: 'info', msg: '新しいメンバーがチームに参加しました。', time: '昨日', unread: true },
{ id: nextId(), type: 'success', msg: '先週のレポートを共有しました。', time: '3日前', unread: false },
];
const MAX_ITEMS = 8;
const NEW_ARRIVALS = [
{ type: 'info', msg: 'コメントが追加されました。' },
{ type: 'success', msg: 'バックアップが完了しました。' },
{ type: 'warning', msg: 'まもなくプランの更新期限です。' },
];
let arrivalIndex = 0;
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[c]));
const reduceMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
function updateBadge() {
const unread = items.filter((i) => i.unread).length;
badge.textContent = unread > 9 ? '9+' : String(unread);
badge.dataset.zero = unread === 0 ? 'true' : 'false';
bell.setAttribute('aria-label', unread > 0 ? `通知を開く(未読${unread}件)` : '通知を開く');
}
function render() {
if (!items.length) {
list.innerHTML = '<li class="np-empty">新しい通知はありません</li>';
updateBadge();
return;
}
list.innerHTML = items.map((i) => `
<li class="np-item${i.unread ? ' is-unread' : ''}" data-id="${i.id}" role="button" tabindex="0"
aria-label="${esc(TYPE_LABEL[i.type] || '')}: ${esc(i.msg)}${i.unread ? '(未読)' : ''}">
<span class="np-item__dot" style="--c:var(--np-${i.type})" aria-hidden="true"></span>
<span class="np-item__body">
<p class="np-item__msg">${esc(i.msg)}</p>
<p class="np-item__time">${esc(i.time)}</p>
</span>
</li>`).join('');
updateBadge();
}
function markRead(id) {
const it = items.find((x) => x.id === id);
if (it && it.unread) {
it.unread = false;
render();
}
}
list.addEventListener('click', (e) => {
const li = e.target.closest('.np-item[data-id]');
if (li) markRead(li.dataset.id);
});
list.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const li = e.target.closest('.np-item[data-id]');
if (li) {
e.preventDefault();
markRead(li.dataset.id);
}
});
if (markAllBtn) {
markAllBtn.addEventListener('click', () => {
items.forEach((i) => { i.unread = false; });
render();
});
}
function togglePanel(open) {
const willOpen = open !== undefined ? open : panel.hidden;
panel.hidden = !willOpen;
bell.setAttribute('aria-expanded', String(willOpen));
}
bell.addEventListener('click', () => togglePanel());
// パネル内クリック(既読化などでlist.innerHTMLを差し替える)がdocumentまで伝播すると、
// クリック元のノードが既に置き換わっていて「外側クリック」と誤判定されるため、
// パネル内で完結したクリックはここで止める。
panel.addEventListener('click', (e) => { e.stopPropagation(); });
document.addEventListener('click', (e) => {
if (panel.hidden) return;
if (!panel.contains(e.target) && e.target !== bell && !bell.contains(e.target)) togglePanel(false);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !panel.hidden) {
togglePanel(false);
bell.focus();
}
});
function ringBell() {
if (reduceMotion()) return;
bell.classList.remove('is-ringing');
void bell.offsetWidth; // reflowでアニメーションを再トリガー
bell.classList.add('is-ringing');
badge.classList.remove('is-bump');
void badge.offsetWidth;
badge.classList.add('is-bump');
}
function arriveNotification() {
if (arrivalIndex >= NEW_ARRIVALS.length) return;
const a = NEW_ARRIVALS[arrivalIndex];
arrivalIndex += 1;
items.unshift({ id: nextId(), type: a.type, msg: a.msg, time: 'たった今', unread: true });
if (items.length > MAX_ITEMS) items.length = MAX_ITEMS;
render();
ringBell();
if (announce) announce.textContent = `新しい通知: ${a.msg}`;
}
render();
const timer = setInterval(() => {
arriveNotification();
if (arrivalIndex >= NEW_ARRIVALS.length) clearInterval(timer);
}, 9000);
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「通知パネル」の効果を追加してください。
# 追加してほしい効果
通知パネル(アラート & 空状態)
ベルアイコンと未読数バッジを備えた通知パネル。クリックで開く一覧は種別ごとに色分けされ、クリックで既読化、新着も自動で届きます。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 通知パネル: ベルアイコン+未読数バッジ。クリックでドロップダウンを開閉し、
クリックで既読化、しばらくすると新着通知が自動で届く(ベルが小さく揺れる)。 -->
<div class="np-stage">
<div class="np-topbar">
<span class="np-topbar__brand">Dashboard</span>
<div class="np-bell-wrap">
<button
type="button"
class="np-bell"
id="npBell"
aria-haspopup="true"
aria-expanded="false"
aria-controls="npPanel"
aria-label="通知を開く"
>
<svg class="np-bell__svg" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2a6 6 0 0 0-6 6v3.2c0 .53-.21 1.04-.59 1.41L4 14v1h16v-1l-1.41-1.39a2 2 0 0 1-.59-1.41V8a6 6 0 0 0-6-6z" fill="currentColor"/>
<path d="M9.5 18a2.5 2.5 0 0 0 5 0h-5z" fill="currentColor"/>
</svg>
<span class="np-bell__badge" id="npBadge" data-zero="false">0</span>
</button>
<div class="np-panel" id="npPanel" role="region" aria-label="通知一覧" hidden>
<div class="np-panel__head">
<span>通知</span>
<button type="button" class="np-panel__markall" id="npMarkAll">すべて既読にする</button>
</div>
<ul class="np-list" id="npList"></ul>
</div>
</div>
</div>
<div class="np-page">
<h1>ようこそ</h1>
<p>ベルアイコンをクリックすると通知の一覧が開きます。しばらくすると新着が届きます。</p>
</div>
<!-- 新着通知は視覚的な一覧とは別に、ここでスクリーンリーダーへ簡潔に読み上げる -->
<div class="np-sr-only" id="npAnnounce" aria-live="polite" aria-atomic="true"></div>
</div>
【CSS】
/* 通知パネル: ベル+未読バッジ、ドロップダウン内は種別ごとに色分けしたドットで意味を示す */
* { box-sizing: border-box; }
:root{
--np-ink: #1f2430;
--np-muted: #6b7280;
--np-success: #16a34a;
--np-info: #2563eb;
--np-warning: #b45309;
--np-error: #dc2626;
}
body{
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", system-ui, -apple-system, "Hiragino Sans", sans-serif;
color: var(--np-ink);
background: #f4f5fa;
}
.np-stage{ min-height: 100vh; display: flex; flex-direction: column; }
.np-topbar{
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 22px;
background: #fff;
border-bottom: 1px solid #e9eaf2;
}
.np-topbar__brand{ font-weight: 700; }
.np-bell-wrap{ position: relative; }
.np-bell{
position: relative;
width: 40px; height: 40px;
display: grid;
place-items: center;
border-radius: 50%;
border: none;
background: #f0f1f6;
color: #454b5c;
cursor: pointer;
transition: background .2s ease;
}
.np-bell:hover{ background: #e5e7f0; }
.np-bell:focus-visible{ outline: 2px solid var(--np-info); outline-offset: 2px; }
.np-bell__svg{ width: 20px; height: 20px; }
/* 新着が届いた瞬間にだけ小さく揺れる(待機中の演出は自動着信のたびに発生) */
.np-bell.is-ringing{ animation: npRing .6s ease; }
@keyframes npRing{
0%, 100%{ transform: rotate(0); }
20%{ transform: rotate(-14deg); }
40%{ transform: rotate(11deg); }
60%{ transform: rotate(-7deg); }
80%{ transform: rotate(4deg); }
}
.np-bell__badge{
position: absolute;
top: -2px; right: -2px;
min-width: 18px; height: 18px;
padding: 0 4px;
border-radius: 999px;
background: var(--np-error);
color: #fff;
font-size: .68rem;
font-weight: 700;
display: grid;
place-items: center;
border: 2px solid #fff;
}
.np-bell__badge[data-zero="true"]{ display: none; }
.np-bell__badge.is-bump{ animation: npBump .35s ease; }
@keyframes npBump{
0%{ transform: scale(1); }
40%{ transform: scale(1.35); }
100%{ transform: scale(1); }
}
.np-panel{
position: absolute;
top: calc(100% + 10px);
right: 0;
width: min(340px, 88vw);
background: #fff;
border: 1px solid #e9eaf2;
border-radius: 14px;
box-shadow: 0 24px 50px -20px rgba(20, 24, 40, .25);
overflow: hidden;
animation: npOpen .22s cubic-bezier(.2,.9,.3,1.2);
z-index: 70;
}
.np-panel[hidden]{ display: none; }
@keyframes npOpen{
from{ opacity: 0; transform: translateY(-6px) scale(.98); }
to{ opacity: 1; transform: none; }
}
.np-panel__head{
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid #eef0f6;
font-size: .85rem;
font-weight: 700;
}
.np-panel__markall{
font: inherit;
font-size: .76rem;
font-weight: 600;
border: none;
background: none;
color: var(--np-info);
cursor: pointer;
}
.np-panel__markall:hover{ text-decoration: underline; }
.np-panel__markall:focus-visible{ outline: 2px solid var(--np-info); outline-offset: 2px; }
.np-list{ list-style: none; margin: 0; padding: 0; max-height: 320px; overflow-y: auto; }
.np-item{
display: flex;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid #f2f3f8;
cursor: pointer;
background: #fff;
transition: background .15s ease;
}
.np-item:last-child{ border-bottom: none; }
.np-item:hover{ background: #f7f8fc; }
.np-item:focus-visible{ outline: 2px solid var(--np-info); outline-offset: -2px; }
.np-item__dot{
flex: none;
width: 9px; height: 9px;
margin-top: 6px;
border-radius: 50%;
background: var(--c);
}
.np-item:not(.is-unread) .np-item__dot{ background: #d5d8e2; }
.np-item__body{ flex: 1; min-width: 0; }
.np-item__msg{ margin: 0; font-size: .84rem; line-height: 1.5; color: var(--np-ink); }
.np-item.is-unread .np-item__msg{ font-weight: 700; }
.np-item__time{ margin: 2px 0 0; font-size: .72rem; color: var(--np-muted); }
.np-empty{ padding: 26px 16px; text-align: center; font-size: .82rem; color: var(--np-muted); }
.np-page{
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
padding: 60px 24px;
}
.np-page h1{ margin: 0; font-size: 1.3rem; }
.np-page p{ margin: 0; color: var(--np-muted); max-width: 360px; font-size: .88rem; }
/* スクリーンリーダー専用の告知領域(視覚的には非表示) */
.np-sr-only{
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0; border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce){
.np-bell.is-ringing, .np-bell__badge.is-bump, .np-panel{ animation: none; }
}
【JavaScript】
// 通知パネル: ベル+未読バッジ、開閉/既読化/新着の自動着信を管理する
(() => {
const bell = document.getElementById('npBell');
const badge = document.getElementById('npBadge');
const panel = document.getElementById('npPanel');
const list = document.getElementById('npList');
const markAllBtn = document.getElementById('npMarkAll');
const announce = document.getElementById('npAnnounce');
if (!bell || !badge || !panel || !list) return; // null安全
const TYPE_LABEL = { success: '成功', info: 'お知らせ', warning: '警告', error: 'エラー' };
let seq = 0;
const nextId = () => 'n' + (seq += 1);
const items = [
{ id: nextId(), type: 'error', msg: '支払いに失敗しました。カード情報をご確認ください。', time: '3分前', unread: true },
{ id: nextId(), type: 'warning', msg: 'ストレージの残り容量が少なくなっています。', time: '25分前', unread: true },
{ id: nextId(), type: 'success', msg: 'レポートの生成が完了しました。', time: '1時間前', unread: true },
{ id: nextId(), type: 'info', msg: '新しいメンバーがチームに参加しました。', time: '昨日', unread: true },
{ id: nextId(), type: 'success', msg: '先週のレポートを共有しました。', time: '3日前', unread: false },
];
const MAX_ITEMS = 8;
const NEW_ARRIVALS = [
{ type: 'info', msg: 'コメントが追加されました。' },
{ type: 'success', msg: 'バックアップが完了しました。' },
{ type: 'warning', msg: 'まもなくプランの更新期限です。' },
];
let arrivalIndex = 0;
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[c]));
const reduceMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
function updateBadge() {
const unread = items.filter((i) => i.unread).length;
badge.textContent = unread > 9 ? '9+' : String(unread);
badge.dataset.zero = unread === 0 ? 'true' : 'false';
bell.setAttribute('aria-label', unread > 0 ? `通知を開く(未読${unread}件)` : '通知を開く');
}
function render() {
if (!items.length) {
list.innerHTML = '<li class="np-empty">新しい通知はありません</li>';
updateBadge();
return;
}
list.innerHTML = items.map((i) => `
<li class="np-item${i.unread ? ' is-unread' : ''}" data-id="${i.id}" role="button" tabindex="0"
aria-label="${esc(TYPE_LABEL[i.type] || '')}: ${esc(i.msg)}${i.unread ? '(未読)' : ''}">
<span class="np-item__dot" style="--c:var(--np-${i.type})" aria-hidden="true"></span>
<span class="np-item__body">
<p class="np-item__msg">${esc(i.msg)}</p>
<p class="np-item__time">${esc(i.time)}</p>
</span>
</li>`).join('');
updateBadge();
}
function markRead(id) {
const it = items.find((x) => x.id === id);
if (it && it.unread) {
it.unread = false;
render();
}
}
list.addEventListener('click', (e) => {
const li = e.target.closest('.np-item[data-id]');
if (li) markRead(li.dataset.id);
});
list.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const li = e.target.closest('.np-item[data-id]');
if (li) {
e.preventDefault();
markRead(li.dataset.id);
}
});
if (markAllBtn) {
markAllBtn.addEventListener('click', () => {
items.forEach((i) => { i.unread = false; });
render();
});
}
function togglePanel(open) {
const willOpen = open !== undefined ? open : panel.hidden;
panel.hidden = !willOpen;
bell.setAttribute('aria-expanded', String(willOpen));
}
bell.addEventListener('click', () => togglePanel());
// パネル内クリック(既読化などでlist.innerHTMLを差し替える)がdocumentまで伝播すると、
// クリック元のノードが既に置き換わっていて「外側クリック」と誤判定されるため、
// パネル内で完結したクリックはここで止める。
panel.addEventListener('click', (e) => { e.stopPropagation(); });
document.addEventListener('click', (e) => {
if (panel.hidden) return;
if (!panel.contains(e.target) && e.target !== bell && !bell.contains(e.target)) togglePanel(false);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !panel.hidden) {
togglePanel(false);
bell.focus();
}
});
function ringBell() {
if (reduceMotion()) return;
bell.classList.remove('is-ringing');
void bell.offsetWidth; // reflowでアニメーションを再トリガー
bell.classList.add('is-ringing');
badge.classList.remove('is-bump');
void badge.offsetWidth;
badge.classList.add('is-bump');
}
function arriveNotification() {
if (arrivalIndex >= NEW_ARRIVALS.length) return;
const a = NEW_ARRIVALS[arrivalIndex];
arrivalIndex += 1;
items.unshift({ id: nextId(), type: a.type, msg: a.msg, time: 'たった今', unread: true });
if (items.length > MAX_ITEMS) items.length = MAX_ITEMS;
render();
ringBell();
if (announce) announce.textContent = `新しい通知: ${a.msg}`;
}
render();
const timer = setInterval(() => {
arriveNotification();
if (arrivalIndex >= NEW_ARRIVALS.length) clearInterval(timer);
}, 9000);
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。