遊べるUI 中級

絵文字リアクション

ボタンを長押しかクリックすると絵文字パレットが弧を描いてポップアップし、選んだ絵文字が跳ねて確定・カウントされるリアクションボタン。

#css#js#reaction#animation

ライブデモ

コード

HTML
<!-- 絵文字リアクション: 長押し/クリックで絵文字パレットが弧状にポップし、選ぶと跳ねて確定・カウントされる -->
<div class="stage">
  <div class="emoji-reactions">
    <div class="emoji-reactions__palette" role="menu" aria-label="絵文字を選ぶ">
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="👍" aria-label="いいね">👍</button>
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="❤️" aria-label="ハート">❤️</button>
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="😂" aria-label="笑い">😂</button>
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="😮" aria-label="驚き">😮</button>
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="🎉" aria-label="祝福">🎉</button>
    </div>
    <button class="emoji-reactions__main" type="button" aria-label="リアクションを追加" aria-haspopup="true" aria-expanded="false">
      <span aria-hidden="true">😊</span>
      <span class="emoji-reactions__main-label">追加</span>
    </button>
    <div class="emoji-reactions__chips"></div>
  </div>
  <p class="hint">ボタンを長押しかクリックでリアクションを選べます</p>
</div>
CSS
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  overflow: hidden;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: radial-gradient(circle at 50% 30%, #201a30 0%, #0d0e1a 72%);
  color: #f4f5fb;
}

.stage { display: grid; place-items: center; gap: 16px; padding: 20px; }

.emoji-reactions {
  position: relative;
  display: grid;
  place-items: center;
  gap: 14px;
  padding-top: 6px;
}

.emoji-reactions__main {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  border: none;
  cursor: pointer;
  padding: 13px 22px;
  border-radius: 999px;
  font: 700 14px/1 "Segoe UI", system-ui, sans-serif;
  color: #f4f5fb;
  background: rgba(255, 255, 255, .06);
  box-shadow: 0 10px 24px rgba(0, 0, 0, .4);
  -webkit-tap-highlight-color: transparent;
  animation: reactions-idle 3s ease-in-out infinite;
}
.emoji-reactions__main span:first-child { font-size: 18px; }
.emoji-reactions__main:focus-visible { outline: 2px solid #c4b5fd; outline-offset: 4px; }
.emoji-reactions.is-open .emoji-reactions__main { animation: none; box-shadow: 0 10px 24px rgba(0, 0, 0, .4), 0 0 16px rgba(196, 181, 253, .5); }

@keyframes reactions-idle {
  0%, 100% { box-shadow: 0 10px 24px rgba(0, 0, 0, .4), 0 0 0 rgba(196, 181, 253, 0); }
  50%      { box-shadow: 0 10px 24px rgba(0, 0, 0, .4), 0 0 14px rgba(196, 181, 253, .35); }
}

/* 弧状のパレット。各ボタンの着地点(--x, --y)と時間差(--d)はJSが計算して設定する */
.emoji-reactions__palette {
  position: absolute;
  left: 50%;
  bottom: calc(100% + 4px);
  width: 0;
  height: 0;
  pointer-events: none;
}

.emoji-reactions__pick {
  position: absolute;
  left: 0;
  top: 0;
  margin-left: -21px;
  margin-top: -21px;
  width: 42px;
  height: 42px;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  display: grid;
  place-items: center;
  font-size: 20px;
  line-height: 1;
  background: linear-gradient(145deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, .05));
  box-shadow: 0 8px 18px rgba(0, 0, 0, .45);
  opacity: 0;
  transform: translate(0, 0) scale(.2);
  pointer-events: none;
  transition: transform .18s ease, opacity .18s ease;
  -webkit-tap-highlight-color: transparent;
}
.emoji-reactions__pick:focus-visible { outline: 2px solid #c4b5fd; outline-offset: 2px; }

.emoji-reactions.is-open .emoji-reactions__pick {
  opacity: 1;
  transform: translate(var(--x, 0px), var(--y, 0px)) scale(1);
  pointer-events: auto;
  transition: transform .5s cubic-bezier(.34, 1.56, .64, 1) var(--d, 0ms), opacity .3s ease var(--d, 0ms);
}

/* 選択確定の弾み。位置(--x, --y)は保ったままスケールだけ弾ませる */
.emoji-reactions__pick.is-confirm {
  animation: pick-confirm .5s cubic-bezier(.34, 1.56, .64, 1);
}
@keyframes pick-confirm {
  0%   { transform: translate(var(--x, 0px), var(--y, 0px)) scale(1); }
  40%  { transform: translate(var(--x, 0px), var(--y, 0px)) scale(1.45); }
  70%  { transform: translate(var(--x, 0px), var(--y, 0px)) scale(.9); }
  100% { transform: translate(var(--x, 0px), var(--y, 0px)) scale(1); }
}

.emoji-reactions__chips {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  max-width: min(80vw, 320px);
  min-height: 30px;
}

.emoji-reactions__chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: none;
  cursor: pointer;
  padding: 6px 12px;
  border-radius: 999px;
  font: 600 13px/1 "Segoe UI", system-ui, sans-serif;
  color: #f4f5fb;
  background: rgba(255, 255, 255, .07);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .1);
}
.emoji-reactions__chip span:first-child { font-size: 15px; }
.emoji-reactions__chip-count { font-variant-numeric: tabular-nums; color: rgba(244, 245, 251, .65); }
.emoji-reactions__chip:focus-visible { outline: 2px solid #c4b5fd; outline-offset: 2px; }

.emoji-reactions__chip.is-bump { animation: chip-bump .4s cubic-bezier(.34, 1.56, .64, 1); }
@keyframes chip-bump {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.18); }
  100% { transform: scale(1); }
}

.hint { margin: 0; font-size: 13px; color: rgba(244, 245, 251, .5); text-align: center; }

@media (prefers-reduced-motion: reduce) {
  .emoji-reactions__main { animation: none; }
  .emoji-reactions.is-open .emoji-reactions__pick {
    transition: transform .12s ease, opacity .12s ease;
    transition-delay: 0s;
  }
  .emoji-reactions__pick.is-confirm,
  .emoji-reactions__chip.is-bump { animation: none; }
}
JavaScript
// 絵文字リアクション: 長押し/クリックでパレットを弧状に開き、選んだ絵文字を跳ねて確定・カウントする
(() => {
  const root = document.querySelector('.emoji-reactions');
  const mainBtn = document.querySelector('.emoji-reactions__main');
  const palette = document.querySelector('.emoji-reactions__palette');
  const picks = palette ? Array.from(palette.querySelectorAll('.emoji-reactions__pick')) : [];
  const chipsWrap = document.querySelector('.emoji-reactions__chips');
  if (!root || !mainBtn || !palette || !picks.length || !chipsWrap) return; // null安全

  // 各絵文字ボタンの着地点を弧状に計算し、CSSカスタムプロパティとして設定する
  const RADIUS = 78;
  const SPREAD_DEG = 130;
  picks.forEach((pick, idx) => {
    const n = picks.length;
    const t = n === 1 ? 0 : idx / (n - 1) - 0.5; // -0.5..0.5
    const angle = (t * SPREAD_DEG * Math.PI) / 180;
    const x = Math.sin(angle) * RADIUS;
    const y = -Math.cos(angle) * RADIUS; // 上向きの弧
    pick.style.setProperty('--x', `${x.toFixed(1)}px`);
    pick.style.setProperty('--y', `${y.toFixed(1)}px`);
    pick.style.setProperty('--d', `${idx * 45}ms`);
  });

  const counts = new Map(); // emoji -> count
  const chipEls = new Map(); // emoji -> element

  let isOpen = false;
  let longPressFired = false;
  let pressTimer = null;

  const setPicksFocusable = (on) => {
    picks.forEach((p) => { p.tabIndex = on ? 0 : -1; });
  };

  const openPalette = () => {
    if (isOpen) return;
    isOpen = true;
    root.classList.add('is-open');
    mainBtn.setAttribute('aria-expanded', 'true');
    setPicksFocusable(true);
  };
  const closePalette = () => {
    if (!isOpen) return;
    isOpen = false;
    root.classList.remove('is-open');
    mainBtn.setAttribute('aria-expanded', 'false');
    setPicksFocusable(false);
  };
  const togglePalette = () => (isOpen ? closePalette() : openPalette());

  const bumpChip = (emoji) => {
    const next = (counts.get(emoji) || 0) + 1;
    counts.set(emoji, next);
    let chip = chipEls.get(emoji);
    if (!chip) {
      chip = document.createElement('button');
      chip.type = 'button';
      chip.className = 'emoji-reactions__chip';
      chip.dataset.emoji = emoji;
      chip.addEventListener('click', () => selectEmoji(emoji, chip));
      chipsWrap.appendChild(chip);
      chipEls.set(emoji, chip);
    }
    chip.innerHTML = `<span aria-hidden="true">${emoji}</span><span class="emoji-reactions__chip-count">${next}</span>`;
    chip.setAttribute('aria-label', `${emoji} ${next}件`);

    chip.classList.remove('is-bump');
    void chip.offsetWidth; // reflow
    chip.classList.add('is-bump');
  };

  const selectEmoji = (emoji, sourceEl) => {
    bumpChip(emoji);
    if (sourceEl && sourceEl.classList.contains('emoji-reactions__pick')) {
      sourceEl.classList.remove('is-confirm');
      void sourceEl.offsetWidth; // reflow
      sourceEl.classList.add('is-confirm');
      window.setTimeout(closePalette, 320);
    }
  };

  picks.forEach((pick) => {
    pick.addEventListener('click', () => {
      selectEmoji(pick.dataset.emoji || pick.textContent.trim(), pick);
    });
  });

  mainBtn.addEventListener('pointerdown', () => {
    longPressFired = false;
    pressTimer = window.setTimeout(() => {
      longPressFired = true;
      openPalette();
    }, 450);
  });
  ['pointerup', 'pointerleave', 'pointercancel'].forEach((evt) => {
    mainBtn.addEventListener(evt, () => window.clearTimeout(pressTimer));
  });
  mainBtn.addEventListener('click', () => {
    if (longPressFired) { longPressFired = false; return; }
    togglePalette();
  });

  document.addEventListener('click', (e) => {
    if (!isOpen || root.contains(e.target)) return;
    closePalette();
  });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && isOpen) { closePalette(); mainBtn.focus(); }
  });
})();

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

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

# 追加してほしい効果
絵文字リアクション(遊べるUI)
ボタンを長押しかクリックすると絵文字パレットが弧を描いてポップアップし、選んだ絵文字が跳ねて確定・カウントされるリアクションボタン。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 絵文字リアクション: 長押し/クリックで絵文字パレットが弧状にポップし、選ぶと跳ねて確定・カウントされる -->
<div class="stage">
  <div class="emoji-reactions">
    <div class="emoji-reactions__palette" role="menu" aria-label="絵文字を選ぶ">
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="👍" aria-label="いいね">👍</button>
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="❤️" aria-label="ハート">❤️</button>
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="😂" aria-label="笑い">😂</button>
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="😮" aria-label="驚き">😮</button>
      <button class="emoji-reactions__pick" type="button" role="menuitem" tabindex="-1" data-emoji="🎉" aria-label="祝福">🎉</button>
    </div>
    <button class="emoji-reactions__main" type="button" aria-label="リアクションを追加" aria-haspopup="true" aria-expanded="false">
      <span aria-hidden="true">😊</span>
      <span class="emoji-reactions__main-label">追加</span>
    </button>
    <div class="emoji-reactions__chips"></div>
  </div>
  <p class="hint">ボタンを長押しかクリックでリアクションを選べます</p>
</div>

【CSS】
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  overflow: hidden;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: radial-gradient(circle at 50% 30%, #201a30 0%, #0d0e1a 72%);
  color: #f4f5fb;
}

.stage { display: grid; place-items: center; gap: 16px; padding: 20px; }

.emoji-reactions {
  position: relative;
  display: grid;
  place-items: center;
  gap: 14px;
  padding-top: 6px;
}

.emoji-reactions__main {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  border: none;
  cursor: pointer;
  padding: 13px 22px;
  border-radius: 999px;
  font: 700 14px/1 "Segoe UI", system-ui, sans-serif;
  color: #f4f5fb;
  background: rgba(255, 255, 255, .06);
  box-shadow: 0 10px 24px rgba(0, 0, 0, .4);
  -webkit-tap-highlight-color: transparent;
  animation: reactions-idle 3s ease-in-out infinite;
}
.emoji-reactions__main span:first-child { font-size: 18px; }
.emoji-reactions__main:focus-visible { outline: 2px solid #c4b5fd; outline-offset: 4px; }
.emoji-reactions.is-open .emoji-reactions__main { animation: none; box-shadow: 0 10px 24px rgba(0, 0, 0, .4), 0 0 16px rgba(196, 181, 253, .5); }

@keyframes reactions-idle {
  0%, 100% { box-shadow: 0 10px 24px rgba(0, 0, 0, .4), 0 0 0 rgba(196, 181, 253, 0); }
  50%      { box-shadow: 0 10px 24px rgba(0, 0, 0, .4), 0 0 14px rgba(196, 181, 253, .35); }
}

/* 弧状のパレット。各ボタンの着地点(--x, --y)と時間差(--d)はJSが計算して設定する */
.emoji-reactions__palette {
  position: absolute;
  left: 50%;
  bottom: calc(100% + 4px);
  width: 0;
  height: 0;
  pointer-events: none;
}

.emoji-reactions__pick {
  position: absolute;
  left: 0;
  top: 0;
  margin-left: -21px;
  margin-top: -21px;
  width: 42px;
  height: 42px;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  display: grid;
  place-items: center;
  font-size: 20px;
  line-height: 1;
  background: linear-gradient(145deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, .05));
  box-shadow: 0 8px 18px rgba(0, 0, 0, .45);
  opacity: 0;
  transform: translate(0, 0) scale(.2);
  pointer-events: none;
  transition: transform .18s ease, opacity .18s ease;
  -webkit-tap-highlight-color: transparent;
}
.emoji-reactions__pick:focus-visible { outline: 2px solid #c4b5fd; outline-offset: 2px; }

.emoji-reactions.is-open .emoji-reactions__pick {
  opacity: 1;
  transform: translate(var(--x, 0px), var(--y, 0px)) scale(1);
  pointer-events: auto;
  transition: transform .5s cubic-bezier(.34, 1.56, .64, 1) var(--d, 0ms), opacity .3s ease var(--d, 0ms);
}

/* 選択確定の弾み。位置(--x, --y)は保ったままスケールだけ弾ませる */
.emoji-reactions__pick.is-confirm {
  animation: pick-confirm .5s cubic-bezier(.34, 1.56, .64, 1);
}
@keyframes pick-confirm {
  0%   { transform: translate(var(--x, 0px), var(--y, 0px)) scale(1); }
  40%  { transform: translate(var(--x, 0px), var(--y, 0px)) scale(1.45); }
  70%  { transform: translate(var(--x, 0px), var(--y, 0px)) scale(.9); }
  100% { transform: translate(var(--x, 0px), var(--y, 0px)) scale(1); }
}

.emoji-reactions__chips {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  max-width: min(80vw, 320px);
  min-height: 30px;
}

.emoji-reactions__chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: none;
  cursor: pointer;
  padding: 6px 12px;
  border-radius: 999px;
  font: 600 13px/1 "Segoe UI", system-ui, sans-serif;
  color: #f4f5fb;
  background: rgba(255, 255, 255, .07);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .1);
}
.emoji-reactions__chip span:first-child { font-size: 15px; }
.emoji-reactions__chip-count { font-variant-numeric: tabular-nums; color: rgba(244, 245, 251, .65); }
.emoji-reactions__chip:focus-visible { outline: 2px solid #c4b5fd; outline-offset: 2px; }

.emoji-reactions__chip.is-bump { animation: chip-bump .4s cubic-bezier(.34, 1.56, .64, 1); }
@keyframes chip-bump {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.18); }
  100% { transform: scale(1); }
}

.hint { margin: 0; font-size: 13px; color: rgba(244, 245, 251, .5); text-align: center; }

@media (prefers-reduced-motion: reduce) {
  .emoji-reactions__main { animation: none; }
  .emoji-reactions.is-open .emoji-reactions__pick {
    transition: transform .12s ease, opacity .12s ease;
    transition-delay: 0s;
  }
  .emoji-reactions__pick.is-confirm,
  .emoji-reactions__chip.is-bump { animation: none; }
}

【JavaScript】
// 絵文字リアクション: 長押し/クリックでパレットを弧状に開き、選んだ絵文字を跳ねて確定・カウントする
(() => {
  const root = document.querySelector('.emoji-reactions');
  const mainBtn = document.querySelector('.emoji-reactions__main');
  const palette = document.querySelector('.emoji-reactions__palette');
  const picks = palette ? Array.from(palette.querySelectorAll('.emoji-reactions__pick')) : [];
  const chipsWrap = document.querySelector('.emoji-reactions__chips');
  if (!root || !mainBtn || !palette || !picks.length || !chipsWrap) return; // null安全

  // 各絵文字ボタンの着地点を弧状に計算し、CSSカスタムプロパティとして設定する
  const RADIUS = 78;
  const SPREAD_DEG = 130;
  picks.forEach((pick, idx) => {
    const n = picks.length;
    const t = n === 1 ? 0 : idx / (n - 1) - 0.5; // -0.5..0.5
    const angle = (t * SPREAD_DEG * Math.PI) / 180;
    const x = Math.sin(angle) * RADIUS;
    const y = -Math.cos(angle) * RADIUS; // 上向きの弧
    pick.style.setProperty('--x', `${x.toFixed(1)}px`);
    pick.style.setProperty('--y', `${y.toFixed(1)}px`);
    pick.style.setProperty('--d', `${idx * 45}ms`);
  });

  const counts = new Map(); // emoji -> count
  const chipEls = new Map(); // emoji -> element

  let isOpen = false;
  let longPressFired = false;
  let pressTimer = null;

  const setPicksFocusable = (on) => {
    picks.forEach((p) => { p.tabIndex = on ? 0 : -1; });
  };

  const openPalette = () => {
    if (isOpen) return;
    isOpen = true;
    root.classList.add('is-open');
    mainBtn.setAttribute('aria-expanded', 'true');
    setPicksFocusable(true);
  };
  const closePalette = () => {
    if (!isOpen) return;
    isOpen = false;
    root.classList.remove('is-open');
    mainBtn.setAttribute('aria-expanded', 'false');
    setPicksFocusable(false);
  };
  const togglePalette = () => (isOpen ? closePalette() : openPalette());

  const bumpChip = (emoji) => {
    const next = (counts.get(emoji) || 0) + 1;
    counts.set(emoji, next);
    let chip = chipEls.get(emoji);
    if (!chip) {
      chip = document.createElement('button');
      chip.type = 'button';
      chip.className = 'emoji-reactions__chip';
      chip.dataset.emoji = emoji;
      chip.addEventListener('click', () => selectEmoji(emoji, chip));
      chipsWrap.appendChild(chip);
      chipEls.set(emoji, chip);
    }
    chip.innerHTML = `<span aria-hidden="true">${emoji}</span><span class="emoji-reactions__chip-count">${next}</span>`;
    chip.setAttribute('aria-label', `${emoji} ${next}件`);

    chip.classList.remove('is-bump');
    void chip.offsetWidth; // reflow
    chip.classList.add('is-bump');
  };

  const selectEmoji = (emoji, sourceEl) => {
    bumpChip(emoji);
    if (sourceEl && sourceEl.classList.contains('emoji-reactions__pick')) {
      sourceEl.classList.remove('is-confirm');
      void sourceEl.offsetWidth; // reflow
      sourceEl.classList.add('is-confirm');
      window.setTimeout(closePalette, 320);
    }
  };

  picks.forEach((pick) => {
    pick.addEventListener('click', () => {
      selectEmoji(pick.dataset.emoji || pick.textContent.trim(), pick);
    });
  });

  mainBtn.addEventListener('pointerdown', () => {
    longPressFired = false;
    pressTimer = window.setTimeout(() => {
      longPressFired = true;
      openPalette();
    }, 450);
  });
  ['pointerup', 'pointerleave', 'pointercancel'].forEach((evt) => {
    mainBtn.addEventListener(evt, () => window.clearTimeout(pressTimer));
  });
  mainBtn.addEventListener('click', () => {
    if (longPressFired) { longPressFired = false; return; }
    togglePalette();
  });

  document.addEventListener('click', (e) => {
    if (!isOpen || root.contains(e.target)) return;
    closePalette();
  });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && isOpen) { closePalette(); mainBtn.focus(); }
  });
})();

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

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