メッセージリアクション

バブルにカーソルを乗せる(またはタップする)とリアクションパレットが浮かび上がり、選んだ絵文字が集計チップとして積み上がっていきます。

#css#js#chat

ライブデモ

コード

HTML
<!-- メッセージリアクション: バブルにホバー(またはタップ)でリアクションパレットが出て、選ぶと絵文字集計チップが増える -->
<div class="stage">
  <section class="cr-panel" aria-label="山口遥さんとのトーク">
    <ul class="cr-list" id="crList">
      <li class="cr-row cr-row--in">
        <span class="cr-avatar" aria-hidden="true">遥</span>
        <div class="cr-col">
          <div class="cr-wrap" data-reactable>
            <p class="cr-bubble">この写真めちゃくちゃ良い!テンション上がる🔥</p>
            <button type="button" class="cr-trigger" data-reaction-trigger aria-label="リアクションを追加" aria-expanded="false">😊+</button>
            <div class="cr-palette" data-palette role="menu" aria-label="リアクションを選ぶ">
              <button type="button" class="cr-palette__item" data-emoji="👍" aria-label="いいね">👍</button>
              <button type="button" class="cr-palette__item" data-emoji="❤️" aria-label="ハート">❤️</button>
              <button type="button" class="cr-palette__item" data-emoji="😂" aria-label="笑い">😂</button>
              <button type="button" class="cr-palette__item" data-emoji="😮" aria-label="驚き">😮</button>
              <button type="button" class="cr-palette__item" data-emoji="🙏" aria-label="感謝">🙏</button>
            </div>
          </div>
          <div class="cr-chips" data-chips>
            <button type="button" class="cr-chip is-active" data-emoji="👍" data-count="2">👍 <span>2</span></button>
          </div>
        </div>
      </li>

      <li class="cr-row cr-row--out">
        <div class="cr-col cr-col--out">
          <div class="cr-wrap" data-reactable>
            <p class="cr-bubble cr-bubble--out">でしょ、現地で見たときの色がそのまま出てる</p>
            <button type="button" class="cr-trigger" data-reaction-trigger aria-label="リアクションを追加" aria-expanded="false">😊+</button>
            <div class="cr-palette" data-palette role="menu" aria-label="リアクションを選ぶ">
              <button type="button" class="cr-palette__item" data-emoji="👍" aria-label="いいね">👍</button>
              <button type="button" class="cr-palette__item" data-emoji="❤️" aria-label="ハート">❤️</button>
              <button type="button" class="cr-palette__item" data-emoji="😂" aria-label="笑い">😂</button>
              <button type="button" class="cr-palette__item" data-emoji="😮" aria-label="驚き">😮</button>
              <button type="button" class="cr-palette__item" data-emoji="🙏" aria-label="感謝">🙏</button>
            </div>
          </div>
          <div class="cr-chips" data-chips></div>
        </div>
      </li>

      <li class="cr-row cr-row--in">
        <span class="cr-avatar" aria-hidden="true">遥</span>
        <div class="cr-col">
          <div class="cr-wrap" data-reactable>
            <p class="cr-bubble">今度撮影のコツ教えてください🙏</p>
            <button type="button" class="cr-trigger" data-reaction-trigger aria-label="リアクションを追加" aria-expanded="false">😊+</button>
            <div class="cr-palette" data-palette role="menu" aria-label="リアクションを選ぶ">
              <button type="button" class="cr-palette__item" data-emoji="👍" aria-label="いいね">👍</button>
              <button type="button" class="cr-palette__item" data-emoji="❤️" aria-label="ハート">❤️</button>
              <button type="button" class="cr-palette__item" data-emoji="😂" aria-label="笑い">😂</button>
              <button type="button" class="cr-palette__item" data-emoji="😮" aria-label="驚き">😮</button>
              <button type="button" class="cr-palette__item" data-emoji="🙏" aria-label="感謝">🙏</button>
            </div>
          </div>
          <div class="cr-chips" data-chips></div>
        </div>
      </li>
    </ul>
    <p class="cr-hint">吹き出しにカーソルを乗せる(またはボタンをタップ)とリアクションを選べます</p>
  </section>
</div>
CSS
/* メッセージリアクション */
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 28px;
  font-family: "Hiragino Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 90% 8%, rgba(244, 114, 182, .14) 0%, transparent 42%),
    radial-gradient(circle at 6% 92%, rgba(129, 140, 248, .1) 0%, transparent 45%),
    #150e18;
  color: #f4eefb;
  overflow-x: hidden;
}

.cr-panel {
  width: min(380px, 92vw);
  border-radius: 22px;
  padding: 22px 18px 18px;
  background: rgba(255, 255, 255, .045);
  border: 1px solid rgba(255, 255, 255, .09);
  box-shadow: 0 30px 70px -30px rgba(0, 0, 0, .75);
}

.cr-list {
  list-style: none;
  margin: 0 0 14px;
  padding: 8px 4px 4px;
  display: grid;
  gap: 30px;
}

.cr-row { display: flex; align-items: flex-start; gap: 8px; }
.cr-row--out { justify-content: flex-end; }

.cr-avatar {
  flex: none;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  color: #38071f;
  font-weight: 700;
  font-size: 12px;
  background: linear-gradient(135deg, #f9a8d4, #f472b6);
}

.cr-col { display: grid; gap: 6px; max-width: 78%; }
.cr-col--out { justify-items: end; }

.cr-wrap { position: relative; width: fit-content; }

.cr-bubble {
  margin: 0;
  padding: 10px 14px;
  font-size: 13.5px;
  line-height: 1.55;
  border-radius: 16px;
  border-bottom-left-radius: 5px;
  background: rgba(255, 255, 255, .08);
  border: 1px solid rgba(255, 255, 255, .07);
  color: #f4eefb;
}
.cr-bubble--out {
  background: linear-gradient(135deg, #f472b6, #db2777);
  border-bottom-left-radius: 16px;
  border-bottom-right-radius: 5px;
  color: #fff;
  box-shadow: 0 10px 22px -12px rgba(219, 39, 119, .7);
}

/* リアクション追加トリガー */
.cr-trigger {
  position: absolute;
  top: -8px;
  right: -8px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, .18);
  background: #221527;
  color: #f4eefb;
  font-size: 11px;
  line-height: 1;
  display: grid;
  place-items: center;
  cursor: pointer;
  opacity: 0;
  transform: scale(.85);
  transition: opacity .18s ease, transform .18s ease;
}
.cr-wrap:hover .cr-trigger,
.cr-wrap:focus-within .cr-trigger,
.cr-wrap.is-open .cr-trigger {
  opacity: 1;
  transform: scale(1);
}

/* リアクションパレット */
.cr-palette {
  position: absolute;
  bottom: 100%;
  left: 0;
  margin-bottom: 10px;
  display: flex;
  gap: 4px;
  padding: 6px;
  border-radius: 999px;
  background: #221527;
  border: 1px solid rgba(255, 255, 255, .12);
  box-shadow: 0 16px 34px -16px rgba(0, 0, 0, .8);
  opacity: 0;
  transform: translateY(6px) scale(.92);
  transform-origin: bottom left;
  pointer-events: none;
  transition: opacity .18s ease, transform .18s cubic-bezier(.22, 1, .36, 1);
  z-index: 2;
}
.cr-row--out .cr-palette { left: auto; right: 0; transform-origin: bottom right; }

.cr-wrap:hover .cr-palette,
.cr-wrap:focus-within .cr-palette,
.cr-wrap.is-open .cr-palette {
  opacity: 1;
  transform: translateY(0) scale(1);
  pointer-events: auto;
}

.cr-palette__item {
  font-size: 17px;
  line-height: 1;
  padding: 6px;
  border: none;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  transition: transform .15s cubic-bezier(.34, 1.56, .64, 1), background .15s ease;
}
.cr-palette__item:hover, .cr-palette__item:focus-visible {
  background: rgba(255, 255, 255, .12);
  transform: translateY(-3px) scale(1.2);
}

/* 集計チップ */
.cr-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.cr-chips:empty { display: none; }

.cr-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font: inherit;
  font-size: 11.5px;
  font-weight: 600;
  padding: 4px 9px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, .12);
  background: rgba(255, 255, 255, .06);
  color: rgba(244, 238, 251, .75);
  cursor: pointer;
  animation: cr-chip-in .25s cubic-bezier(.34, 1.56, .64, 1);
  transition: background .15s ease, border-color .15s ease;
}
.cr-chip.is-active {
  background: rgba(244, 114, 182, .18);
  border-color: rgba(244, 114, 182, .55);
  color: #fbcfe8;
}
.cr-chip:hover { background: rgba(255, 255, 255, .12); }
@keyframes cr-chip-in {
  from { opacity: 0; transform: scale(.6); }
  to { opacity: 1; transform: scale(1); }
}

.cr-hint {
  margin: 0;
  padding-top: 8px;
  border-top: 1px solid rgba(255, 255, 255, .08);
  font-size: 11px;
  text-align: center;
  color: rgba(244, 238, 251, .4);
}

@media (prefers-reduced-motion: reduce) {
  .cr-trigger, .cr-palette, .cr-palette__item, .cr-chip { transition: none; animation: none; }
}

@media (max-width: 380px) {
  .cr-panel { border-radius: 18px; padding: 18px 14px 14px; }
}
JavaScript
// メッセージリアクション: パレットの開閉と、絵文字ごとの集計チップの生成・増減をイベント委譲で処理
(() => {
  const list = document.getElementById('crList');
  if (!list) return; // null安全

  const closeAllPalettes = (exceptWrap) => {
    list.querySelectorAll('.cr-wrap.is-open').forEach((wrap) => {
      if (wrap === exceptWrap) return;
      wrap.classList.remove('is-open');
      const trigger = wrap.querySelector('[data-reaction-trigger]');
      if (trigger) trigger.setAttribute('aria-expanded', 'false');
    });
  };

  const findChip = (chipsEl, emoji) => {
    const chips = chipsEl.querySelectorAll('[data-emoji]');
    for (const chip of chips) {
      if (chip.dataset.emoji === emoji) return chip;
    }
    return null;
  };

  const toggleReaction = (chipsEl, emoji) => {
    if (!chipsEl || !emoji) return;
    let chip = findChip(chipsEl, emoji);

    if (chip && chip.classList.contains('is-active')) {
      // 自分のリアクションを取り消す
      const next = (parseInt(chip.dataset.count, 10) || 1) - 1;
      if (next <= 0) {
        chip.remove();
      } else {
        chip.dataset.count = String(next);
        const span = chip.querySelector('span');
        if (span) span.textContent = String(next);
        chip.classList.remove('is-active');
      }
      return;
    }

    if (chip) {
      // 既存チップに自分の分を追加
      const next = (parseInt(chip.dataset.count, 10) || 0) + 1;
      chip.dataset.count = String(next);
      const span = chip.querySelector('span');
      if (span) span.textContent = String(next);
      chip.classList.add('is-active');
      return;
    }

    // 新規チップを作成
    chip = document.createElement('button');
    chip.type = 'button';
    chip.className = 'cr-chip is-active';
    chip.dataset.emoji = emoji;
    chip.dataset.count = '1';
    chip.append(emoji + ' ');
    const span = document.createElement('span');
    span.textContent = '1';
    chip.appendChild(span);
    chipsEl.appendChild(chip);
  };

  list.addEventListener('click', (e) => {
    // トリガーボタン: パレットの開閉(他は閉じる)
    const trigger = e.target.closest('[data-reaction-trigger]');
    if (trigger) {
      const wrap = trigger.closest('[data-reactable]');
      if (!wrap) return;
      const willOpen = !wrap.classList.contains('is-open');
      closeAllPalettes(willOpen ? wrap : null);
      wrap.classList.toggle('is-open', willOpen);
      trigger.setAttribute('aria-expanded', String(willOpen));
      return;
    }

    // パレット内の絵文字 or 既存チップ: 集計を更新
    const emojiBtn = e.target.closest('[data-emoji]');
    if (emojiBtn) {
      const col = emojiBtn.closest('.cr-col');
      const chipsEl = col ? col.querySelector('[data-chips]') : null;
      toggleReaction(chipsEl, emojiBtn.dataset.emoji);

      if (emojiBtn.classList.contains('cr-palette__item')) {
        const wrap = emojiBtn.closest('[data-reactable]');
        if (wrap) {
          wrap.classList.remove('is-open');
          const t = wrap.querySelector('[data-reaction-trigger]');
          if (t) t.setAttribute('aria-expanded', 'false');
        }
      }
    }
  });

  // パレットの外側をクリックしたら閉じる(タッチ操作向け)
  document.addEventListener('click', (e) => {
    if (!list.contains(e.target)) closeAllPalettes(null);
  });
})();

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

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

# 追加してほしい効果
メッセージリアクション(チャット & メディア)
バブルにカーソルを乗せる(またはタップする)とリアクションパレットが浮かび上がり、選んだ絵文字が集計チップとして積み上がっていきます。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- メッセージリアクション: バブルにホバー(またはタップ)でリアクションパレットが出て、選ぶと絵文字集計チップが増える -->
<div class="stage">
  <section class="cr-panel" aria-label="山口遥さんとのトーク">
    <ul class="cr-list" id="crList">
      <li class="cr-row cr-row--in">
        <span class="cr-avatar" aria-hidden="true">遥</span>
        <div class="cr-col">
          <div class="cr-wrap" data-reactable>
            <p class="cr-bubble">この写真めちゃくちゃ良い!テンション上がる🔥</p>
            <button type="button" class="cr-trigger" data-reaction-trigger aria-label="リアクションを追加" aria-expanded="false">😊+</button>
            <div class="cr-palette" data-palette role="menu" aria-label="リアクションを選ぶ">
              <button type="button" class="cr-palette__item" data-emoji="👍" aria-label="いいね">👍</button>
              <button type="button" class="cr-palette__item" data-emoji="❤️" aria-label="ハート">❤️</button>
              <button type="button" class="cr-palette__item" data-emoji="😂" aria-label="笑い">😂</button>
              <button type="button" class="cr-palette__item" data-emoji="😮" aria-label="驚き">😮</button>
              <button type="button" class="cr-palette__item" data-emoji="🙏" aria-label="感謝">🙏</button>
            </div>
          </div>
          <div class="cr-chips" data-chips>
            <button type="button" class="cr-chip is-active" data-emoji="👍" data-count="2">👍 <span>2</span></button>
          </div>
        </div>
      </li>

      <li class="cr-row cr-row--out">
        <div class="cr-col cr-col--out">
          <div class="cr-wrap" data-reactable>
            <p class="cr-bubble cr-bubble--out">でしょ、現地で見たときの色がそのまま出てる</p>
            <button type="button" class="cr-trigger" data-reaction-trigger aria-label="リアクションを追加" aria-expanded="false">😊+</button>
            <div class="cr-palette" data-palette role="menu" aria-label="リアクションを選ぶ">
              <button type="button" class="cr-palette__item" data-emoji="👍" aria-label="いいね">👍</button>
              <button type="button" class="cr-palette__item" data-emoji="❤️" aria-label="ハート">❤️</button>
              <button type="button" class="cr-palette__item" data-emoji="😂" aria-label="笑い">😂</button>
              <button type="button" class="cr-palette__item" data-emoji="😮" aria-label="驚き">😮</button>
              <button type="button" class="cr-palette__item" data-emoji="🙏" aria-label="感謝">🙏</button>
            </div>
          </div>
          <div class="cr-chips" data-chips></div>
        </div>
      </li>

      <li class="cr-row cr-row--in">
        <span class="cr-avatar" aria-hidden="true">遥</span>
        <div class="cr-col">
          <div class="cr-wrap" data-reactable>
            <p class="cr-bubble">今度撮影のコツ教えてください🙏</p>
            <button type="button" class="cr-trigger" data-reaction-trigger aria-label="リアクションを追加" aria-expanded="false">😊+</button>
            <div class="cr-palette" data-palette role="menu" aria-label="リアクションを選ぶ">
              <button type="button" class="cr-palette__item" data-emoji="👍" aria-label="いいね">👍</button>
              <button type="button" class="cr-palette__item" data-emoji="❤️" aria-label="ハート">❤️</button>
              <button type="button" class="cr-palette__item" data-emoji="😂" aria-label="笑い">😂</button>
              <button type="button" class="cr-palette__item" data-emoji="😮" aria-label="驚き">😮</button>
              <button type="button" class="cr-palette__item" data-emoji="🙏" aria-label="感謝">🙏</button>
            </div>
          </div>
          <div class="cr-chips" data-chips></div>
        </div>
      </li>
    </ul>
    <p class="cr-hint">吹き出しにカーソルを乗せる(またはボタンをタップ)とリアクションを選べます</p>
  </section>
</div>

【CSS】
/* メッセージリアクション */
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 28px;
  font-family: "Hiragino Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 90% 8%, rgba(244, 114, 182, .14) 0%, transparent 42%),
    radial-gradient(circle at 6% 92%, rgba(129, 140, 248, .1) 0%, transparent 45%),
    #150e18;
  color: #f4eefb;
  overflow-x: hidden;
}

.cr-panel {
  width: min(380px, 92vw);
  border-radius: 22px;
  padding: 22px 18px 18px;
  background: rgba(255, 255, 255, .045);
  border: 1px solid rgba(255, 255, 255, .09);
  box-shadow: 0 30px 70px -30px rgba(0, 0, 0, .75);
}

.cr-list {
  list-style: none;
  margin: 0 0 14px;
  padding: 8px 4px 4px;
  display: grid;
  gap: 30px;
}

.cr-row { display: flex; align-items: flex-start; gap: 8px; }
.cr-row--out { justify-content: flex-end; }

.cr-avatar {
  flex: none;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  color: #38071f;
  font-weight: 700;
  font-size: 12px;
  background: linear-gradient(135deg, #f9a8d4, #f472b6);
}

.cr-col { display: grid; gap: 6px; max-width: 78%; }
.cr-col--out { justify-items: end; }

.cr-wrap { position: relative; width: fit-content; }

.cr-bubble {
  margin: 0;
  padding: 10px 14px;
  font-size: 13.5px;
  line-height: 1.55;
  border-radius: 16px;
  border-bottom-left-radius: 5px;
  background: rgba(255, 255, 255, .08);
  border: 1px solid rgba(255, 255, 255, .07);
  color: #f4eefb;
}
.cr-bubble--out {
  background: linear-gradient(135deg, #f472b6, #db2777);
  border-bottom-left-radius: 16px;
  border-bottom-right-radius: 5px;
  color: #fff;
  box-shadow: 0 10px 22px -12px rgba(219, 39, 119, .7);
}

/* リアクション追加トリガー */
.cr-trigger {
  position: absolute;
  top: -8px;
  right: -8px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, .18);
  background: #221527;
  color: #f4eefb;
  font-size: 11px;
  line-height: 1;
  display: grid;
  place-items: center;
  cursor: pointer;
  opacity: 0;
  transform: scale(.85);
  transition: opacity .18s ease, transform .18s ease;
}
.cr-wrap:hover .cr-trigger,
.cr-wrap:focus-within .cr-trigger,
.cr-wrap.is-open .cr-trigger {
  opacity: 1;
  transform: scale(1);
}

/* リアクションパレット */
.cr-palette {
  position: absolute;
  bottom: 100%;
  left: 0;
  margin-bottom: 10px;
  display: flex;
  gap: 4px;
  padding: 6px;
  border-radius: 999px;
  background: #221527;
  border: 1px solid rgba(255, 255, 255, .12);
  box-shadow: 0 16px 34px -16px rgba(0, 0, 0, .8);
  opacity: 0;
  transform: translateY(6px) scale(.92);
  transform-origin: bottom left;
  pointer-events: none;
  transition: opacity .18s ease, transform .18s cubic-bezier(.22, 1, .36, 1);
  z-index: 2;
}
.cr-row--out .cr-palette { left: auto; right: 0; transform-origin: bottom right; }

.cr-wrap:hover .cr-palette,
.cr-wrap:focus-within .cr-palette,
.cr-wrap.is-open .cr-palette {
  opacity: 1;
  transform: translateY(0) scale(1);
  pointer-events: auto;
}

.cr-palette__item {
  font-size: 17px;
  line-height: 1;
  padding: 6px;
  border: none;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  transition: transform .15s cubic-bezier(.34, 1.56, .64, 1), background .15s ease;
}
.cr-palette__item:hover, .cr-palette__item:focus-visible {
  background: rgba(255, 255, 255, .12);
  transform: translateY(-3px) scale(1.2);
}

/* 集計チップ */
.cr-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.cr-chips:empty { display: none; }

.cr-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font: inherit;
  font-size: 11.5px;
  font-weight: 600;
  padding: 4px 9px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, .12);
  background: rgba(255, 255, 255, .06);
  color: rgba(244, 238, 251, .75);
  cursor: pointer;
  animation: cr-chip-in .25s cubic-bezier(.34, 1.56, .64, 1);
  transition: background .15s ease, border-color .15s ease;
}
.cr-chip.is-active {
  background: rgba(244, 114, 182, .18);
  border-color: rgba(244, 114, 182, .55);
  color: #fbcfe8;
}
.cr-chip:hover { background: rgba(255, 255, 255, .12); }
@keyframes cr-chip-in {
  from { opacity: 0; transform: scale(.6); }
  to { opacity: 1; transform: scale(1); }
}

.cr-hint {
  margin: 0;
  padding-top: 8px;
  border-top: 1px solid rgba(255, 255, 255, .08);
  font-size: 11px;
  text-align: center;
  color: rgba(244, 238, 251, .4);
}

@media (prefers-reduced-motion: reduce) {
  .cr-trigger, .cr-palette, .cr-palette__item, .cr-chip { transition: none; animation: none; }
}

@media (max-width: 380px) {
  .cr-panel { border-radius: 18px; padding: 18px 14px 14px; }
}

【JavaScript】
// メッセージリアクション: パレットの開閉と、絵文字ごとの集計チップの生成・増減をイベント委譲で処理
(() => {
  const list = document.getElementById('crList');
  if (!list) return; // null安全

  const closeAllPalettes = (exceptWrap) => {
    list.querySelectorAll('.cr-wrap.is-open').forEach((wrap) => {
      if (wrap === exceptWrap) return;
      wrap.classList.remove('is-open');
      const trigger = wrap.querySelector('[data-reaction-trigger]');
      if (trigger) trigger.setAttribute('aria-expanded', 'false');
    });
  };

  const findChip = (chipsEl, emoji) => {
    const chips = chipsEl.querySelectorAll('[data-emoji]');
    for (const chip of chips) {
      if (chip.dataset.emoji === emoji) return chip;
    }
    return null;
  };

  const toggleReaction = (chipsEl, emoji) => {
    if (!chipsEl || !emoji) return;
    let chip = findChip(chipsEl, emoji);

    if (chip && chip.classList.contains('is-active')) {
      // 自分のリアクションを取り消す
      const next = (parseInt(chip.dataset.count, 10) || 1) - 1;
      if (next <= 0) {
        chip.remove();
      } else {
        chip.dataset.count = String(next);
        const span = chip.querySelector('span');
        if (span) span.textContent = String(next);
        chip.classList.remove('is-active');
      }
      return;
    }

    if (chip) {
      // 既存チップに自分の分を追加
      const next = (parseInt(chip.dataset.count, 10) || 0) + 1;
      chip.dataset.count = String(next);
      const span = chip.querySelector('span');
      if (span) span.textContent = String(next);
      chip.classList.add('is-active');
      return;
    }

    // 新規チップを作成
    chip = document.createElement('button');
    chip.type = 'button';
    chip.className = 'cr-chip is-active';
    chip.dataset.emoji = emoji;
    chip.dataset.count = '1';
    chip.append(emoji + ' ');
    const span = document.createElement('span');
    span.textContent = '1';
    chip.appendChild(span);
    chipsEl.appendChild(chip);
  };

  list.addEventListener('click', (e) => {
    // トリガーボタン: パレットの開閉(他は閉じる)
    const trigger = e.target.closest('[data-reaction-trigger]');
    if (trigger) {
      const wrap = trigger.closest('[data-reactable]');
      if (!wrap) return;
      const willOpen = !wrap.classList.contains('is-open');
      closeAllPalettes(willOpen ? wrap : null);
      wrap.classList.toggle('is-open', willOpen);
      trigger.setAttribute('aria-expanded', String(willOpen));
      return;
    }

    // パレット内の絵文字 or 既存チップ: 集計を更新
    const emojiBtn = e.target.closest('[data-emoji]');
    if (emojiBtn) {
      const col = emojiBtn.closest('.cr-col');
      const chipsEl = col ? col.querySelector('[data-chips]') : null;
      toggleReaction(chipsEl, emojiBtn.dataset.emoji);

      if (emojiBtn.classList.contains('cr-palette__item')) {
        const wrap = emojiBtn.closest('[data-reactable]');
        if (wrap) {
          wrap.classList.remove('is-open');
          const t = wrap.querySelector('[data-reaction-trigger]');
          if (t) t.setAttribute('aria-expanded', 'false');
        }
      }
    }
  });

  // パレットの外側をクリックしたら閉じる(タッチ操作向け)
  document.addEventListener('click', (e) => {
    if (!list.contains(e.target)) closeAllPalettes(null);
  });
})();

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

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