遊べるUI 中級

はじけるハート

クリックのたびにハートが弾性スケールで弾み、ハートや粒が放射状に飛び散ってフェードするいいねボタン。連打しても崩れません。

#js#animation#button#particles

ライブデモ

コード

HTML
<!-- はじけるハート: クリックで弾性スケール+放射状の粒子が飛び散るいいねボタン -->
<div class="stage">
  <button class="heart-burst" type="button" aria-label="いいね">
    <svg class="heart-burst__icon" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M12 21s-7.5-4.6-10-9.2C.3 8.6 1.8 5 5.2 5c2 0 3.3 1.1 4 2.2C9.9 6.1 11.2 5 13.2 5c3.4 0 4.9 3.6 3.2 6.8C19.5 16.4 12 21 12 21z"/>
    </svg>
    <span class="heart-burst__count">128</span>
  </button>
  <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% 32%, #201626 0%, #0d0e1a 72%);
  color: #f4f5fb;
}

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

.heart-burst {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 12px;
  padding: 14px 26px;
  border: none;
  border-radius: 999px;
  cursor: pointer;
  background: rgba(255, 255, 255, .05);
  box-shadow: 0 10px 26px rgba(0, 0, 0, .4);
  transition: transform .12s ease;
  -webkit-tap-highlight-color: transparent;
  animation: heart-idle 3.2s ease-in-out infinite;
}
.heart-burst:active { transform: scale(.94); }
.heart-burst:focus-visible { outline: 2px solid #fb7185; outline-offset: 4px; }

@keyframes heart-idle {
  0%, 100% { box-shadow: 0 10px 26px rgba(0, 0, 0, .4), 0 0 0 rgba(251, 113, 133, 0); }
  50%      { box-shadow: 0 10px 26px rgba(0, 0, 0, .4), 0 0 14px rgba(251, 113, 133, .22); }
}

.heart-burst__icon {
  width: 28px;
  height: 28px;
  fill: #565b74;
  transform-origin: center;
  transition: fill .2s ease;
}

.heart-burst.is-pop .heart-burst__icon {
  fill: #fb7185;
  animation: heart-pop .5s cubic-bezier(.34, 1.56, .64, 1);
}
@keyframes heart-pop {
  0%   { transform: scale(.8); }
  55%  { transform: scale(1.3); }
  100% { transform: scale(1); }
}

.heart-burst__count {
  font-size: 17px;
  font-weight: 700;
  color: #f4f5fb;
  font-variant-numeric: tabular-nums;
  min-width: 2.6ch;
}

/* 放射する粒子(♥や•)。JSが都度生成し、アニメ終了で自身を除去する */
.spark {
  position: absolute;
  top: 50%;
  left: 27px;
  width: 7px;
  height: 7px;
  pointer-events: none;
  transform: translate(-50%, -50%);
  font-size: 12px;
  line-height: 1;
  display: grid;
  place-items: center;
  animation: spark-fly .65s ease-out forwards;
}
@keyframes spark-fly {
  0%   { opacity: 1; transform: translate(-50%, -50%) translate(0, 0) rotate(0deg) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -50%) translate(var(--dx), var(--dy)) rotate(var(--rot)) scale(.3); }
}

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

@media (prefers-reduced-motion: reduce) {
  .heart-burst { animation: none; }
  .heart-burst.is-pop .heart-burst__icon { animation: none; }
  .spark { animation-duration: .01ms; }
}
JavaScript
// はじけるハート: クリックのたびにカウントアップし、弾性スケール+放射状の粒子で反応する
(() => {
  const btn = document.querySelector('.heart-burst');
  const countEl = document.querySelector('.heart-burst__count');
  if (!btn || !countEl) return; // null安全

  const base = parseInt(countEl.textContent, 10) || 0;
  let count = base;
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const COLORS = ['#fb7185', '#fbbf24', '#f9a8d4'];
  const GLYPHS = ['♥', '♥', '•'];

  const burst = () => {
    if (reduce) return;
    const N = 9;
    for (let i = 0; i < N; i++) {
      const s = document.createElement('span');
      s.className = 'spark';
      s.textContent = GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
      const angle = (Math.PI * 2 * i) / N + (Math.random() - 0.5) * 0.5;
      const dist = 30 + Math.random() * 26;
      s.style.setProperty('--dx', `${Math.cos(angle) * dist}px`);
      s.style.setProperty('--dy', `${Math.sin(angle) * dist}px`);
      s.style.setProperty('--rot', `${(Math.random() - 0.5) * 200}deg`);
      s.style.color = COLORS[i % COLORS.length];
      s.addEventListener('animationend', () => s.remove());
      btn.appendChild(s);
    }
  };

  btn.addEventListener('click', () => {
    count += 1;
    countEl.textContent = String(count);

    // 連打しても弾む動きが破綻しないよう、一度クラスを外してreflowしてから再付与する
    btn.classList.remove('is-pop');
    void btn.offsetWidth; // reflow
    btn.classList.add('is-pop');

    burst();
  });
})();

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

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

# 追加してほしい効果
はじけるハート(遊べるUI)
クリックのたびにハートが弾性スケールで弾み、ハートや粒が放射状に飛び散ってフェードするいいねボタン。連打しても崩れません。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- はじけるハート: クリックで弾性スケール+放射状の粒子が飛び散るいいねボタン -->
<div class="stage">
  <button class="heart-burst" type="button" aria-label="いいね">
    <svg class="heart-burst__icon" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M12 21s-7.5-4.6-10-9.2C.3 8.6 1.8 5 5.2 5c2 0 3.3 1.1 4 2.2C9.9 6.1 11.2 5 13.2 5c3.4 0 4.9 3.6 3.2 6.8C19.5 16.4 12 21 12 21z"/>
    </svg>
    <span class="heart-burst__count">128</span>
  </button>
  <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% 32%, #201626 0%, #0d0e1a 72%);
  color: #f4f5fb;
}

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

.heart-burst {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 12px;
  padding: 14px 26px;
  border: none;
  border-radius: 999px;
  cursor: pointer;
  background: rgba(255, 255, 255, .05);
  box-shadow: 0 10px 26px rgba(0, 0, 0, .4);
  transition: transform .12s ease;
  -webkit-tap-highlight-color: transparent;
  animation: heart-idle 3.2s ease-in-out infinite;
}
.heart-burst:active { transform: scale(.94); }
.heart-burst:focus-visible { outline: 2px solid #fb7185; outline-offset: 4px; }

@keyframes heart-idle {
  0%, 100% { box-shadow: 0 10px 26px rgba(0, 0, 0, .4), 0 0 0 rgba(251, 113, 133, 0); }
  50%      { box-shadow: 0 10px 26px rgba(0, 0, 0, .4), 0 0 14px rgba(251, 113, 133, .22); }
}

.heart-burst__icon {
  width: 28px;
  height: 28px;
  fill: #565b74;
  transform-origin: center;
  transition: fill .2s ease;
}

.heart-burst.is-pop .heart-burst__icon {
  fill: #fb7185;
  animation: heart-pop .5s cubic-bezier(.34, 1.56, .64, 1);
}
@keyframes heart-pop {
  0%   { transform: scale(.8); }
  55%  { transform: scale(1.3); }
  100% { transform: scale(1); }
}

.heart-burst__count {
  font-size: 17px;
  font-weight: 700;
  color: #f4f5fb;
  font-variant-numeric: tabular-nums;
  min-width: 2.6ch;
}

/* 放射する粒子(♥や•)。JSが都度生成し、アニメ終了で自身を除去する */
.spark {
  position: absolute;
  top: 50%;
  left: 27px;
  width: 7px;
  height: 7px;
  pointer-events: none;
  transform: translate(-50%, -50%);
  font-size: 12px;
  line-height: 1;
  display: grid;
  place-items: center;
  animation: spark-fly .65s ease-out forwards;
}
@keyframes spark-fly {
  0%   { opacity: 1; transform: translate(-50%, -50%) translate(0, 0) rotate(0deg) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -50%) translate(var(--dx), var(--dy)) rotate(var(--rot)) scale(.3); }
}

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

@media (prefers-reduced-motion: reduce) {
  .heart-burst { animation: none; }
  .heart-burst.is-pop .heart-burst__icon { animation: none; }
  .spark { animation-duration: .01ms; }
}

【JavaScript】
// はじけるハート: クリックのたびにカウントアップし、弾性スケール+放射状の粒子で反応する
(() => {
  const btn = document.querySelector('.heart-burst');
  const countEl = document.querySelector('.heart-burst__count');
  if (!btn || !countEl) return; // null安全

  const base = parseInt(countEl.textContent, 10) || 0;
  let count = base;
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const COLORS = ['#fb7185', '#fbbf24', '#f9a8d4'];
  const GLYPHS = ['♥', '♥', '•'];

  const burst = () => {
    if (reduce) return;
    const N = 9;
    for (let i = 0; i < N; i++) {
      const s = document.createElement('span');
      s.className = 'spark';
      s.textContent = GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
      const angle = (Math.PI * 2 * i) / N + (Math.random() - 0.5) * 0.5;
      const dist = 30 + Math.random() * 26;
      s.style.setProperty('--dx', `${Math.cos(angle) * dist}px`);
      s.style.setProperty('--dy', `${Math.sin(angle) * dist}px`);
      s.style.setProperty('--rot', `${(Math.random() - 0.5) * 200}deg`);
      s.style.color = COLORS[i % COLORS.length];
      s.addEventListener('animationend', () => s.remove());
      btn.appendChild(s);
    }
  };

  btn.addEventListener('click', () => {
    count += 1;
    countEl.textContent = String(count);

    // 連打しても弾む動きが破綻しないよう、一度クラスを外してreflowしてから再付与する
    btn.classList.remove('is-pop');
    void btn.offsetWidth; // reflow
    btn.classList.add('is-pop');

    burst();
  });
})();

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

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