通知パネル

ベルアイコンと未読数バッジを備えた通知パネル。クリックで開く一覧は種別ごとに色分けされ、クリックで既読化、新着も自動で届きます。

#css#js#alert#notification

ライブデモ

コード

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) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[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) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。