紙吹雪サクセスボタン

押すとボタンが完了状態へ変化し、canvasで紙吹雪が物理落下する達成感の演出。購入完了やフォーム送信成功のフィードバックに使えます。

#js#canvas#animation#button

ライブデモ

使用例(お題: SaaS FlowDesk)

この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- FlowDesk: 料金プランカード。申込ボタンを紙吹雪サクセスで演出 -->
<canvas class="confetti" aria-hidden="true"></canvas>
<div class="fd">
  <p class="fd__eyebrow">PRICING</p>
  <h1 class="fd__h1">プランを選んで、すぐ開始</h1>

  <div class="plans">
    <article class="plan">
      <h2 class="plan__name">Starter</h2>
      <p class="plan__price"><b>¥0</b><span>/月</span></p>
      <ul class="plan__list">
        <li>3プロジェクト</li>
        <li>メンバー5名</li>
        <li>基本サポート</li>
      </ul>
      <button class="plan__ghost" type="button">無料で使う</button>
    </article>

    <article class="plan plan--pop">
      <span class="plan__tag">人気</span>
      <h2 class="plan__name">Pro</h2>
      <p class="plan__price"><b>¥1,480</b><span>/月</span></p>
      <ul class="plan__list">
        <li>無制限プロジェクト</li>
        <li>メンバー50名</li>
        <li>自動化・優先サポート</li>
      </ul>
      <button class="cta" type="button">
        <span class="cta__label">Proを申し込む</span>
      </button>
    </article>
  </div>
</div>
CSS
/* FlowDesk SaaS テーマ: 紺/青/白 */
* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 50% -10%, rgba(79,124,255,.24) 0%, transparent 50%),
    #0f1b34;
  color: #fff;
  overflow: hidden;
}

/* 紙吹雪レイヤーは最前面・操作透過 */
.confetti {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 5;
}

.fd {
  height: 400px;
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 6px;
  padding: 0 24px;
}
.fd__eyebrow {
  margin: 0;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .2em;
  color: #7da0ff;
}
.fd__h1 {
  margin: 0 0 10px;
  font-size: 24px;
  font-weight: 800;
}

.plans {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
  width: 100%;
  max-width: 440px;
}
.plan {
  position: relative;
  background: rgba(255,255,255,.05);
  border: 1px solid rgba(255,255,255,.1);
  border-radius: 16px;
  padding: 18px;
  display: grid;
  gap: 10px;
}
.plan--pop {
  background: rgba(79,124,255,.12);
  border-color: rgba(79,124,255,.55);
  box-shadow: 0 16px 40px rgba(79,124,255,.25);
}
.plan__tag {
  position: absolute;
  top: -10px;
  right: 14px;
  font-size: 11px;
  font-weight: 700;
  color: #0f1b34;
  background: #8fb0ff;
  padding: 3px 10px;
  border-radius: 999px;
}
.plan__name {
  margin: 0;
  font-size: 15px;
  font-weight: 700;
  color: #bcd0ff;
}
.plan__price {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: 4px;
}
.plan__price b { font-size: 26px; }
.plan__price span { font-size: 12px; color: rgba(255,255,255,.55); }
.plan__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 6px;
  font-size: 12px;
  color: rgba(255,255,255,.78);
}
.plan__list li::before { content: "✓ "; color: #6f96ff; }

.plan__ghost {
  margin-top: 4px;
  font-size: 13px;
  font-weight: 700;
  color: #cfe0ff;
  background: transparent;
  border: 1px solid rgba(255,255,255,.25);
  padding: 10px;
  border-radius: 10px;
  cursor: pointer;
}

/* 主役: 紙吹雪サクセスボタン */
.cta {
  margin-top: 4px;
  position: relative;
  font-size: 14px;
  font-weight: 700;
  color: #fff;
  background: linear-gradient(135deg, #4f7cff, #6f96ff);
  border: none;
  padding: 12px;
  border-radius: 10px;
  cursor: pointer;
  overflow: hidden;
  box-shadow: 0 10px 26px rgba(79,124,255,.45);
  transition: background .3s ease, transform .15s ease;
}
.cta:hover { filter: brightness(1.07); }
.cta:active { transform: scale(.98); }
.cta.is-done {
  background: linear-gradient(135deg, #2bd980, #16c46a);
  box-shadow: 0 10px 26px rgba(22,196,106,.4);
}

@media (prefers-reduced-motion: reduce) {
  .cta { transition: background .3s ease; }
}
JavaScript
// FlowDesk申込: クリックで完了状態にし、canvasで紙吹雪を物理落下させる
(() => {
  const btn = document.querySelector('.cta');
  const canvas = document.querySelector('.confetti');
  if (!btn || !canvas) return; // null安全

  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const COLORS = ['#4f7cff', '#8fb0ff', '#2bd980', '#ffd166', '#ff7eb0', '#fff'];
  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  let particles = [];
  let rafId = null;

  // 解像度に合わせてcanvasサイズを調整
  const resize = () => {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = canvas.clientWidth * dpr;
    canvas.height = canvas.clientHeight * dpr;
  };
  resize();
  window.addEventListener('resize', resize);

  // ボタン上部から扇状に紙吹雪を放出
  const spawn = () => {
    const r = btn.getBoundingClientRect();
    const cr = canvas.getBoundingClientRect();
    const ox = (r.left + r.width / 2 - cr.left) * dpr;
    const oy = (r.top - cr.top) * dpr;
    for (let i = 0; i < 90; i++) {
      const angle = Math.PI * (1.1 + Math.random() * 0.8);
      const speed = (4 + Math.random() * 7) * dpr;
      particles.push({
        x: ox + (Math.random() - 0.5) * r.width * dpr,
        y: oy,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed - 4 * dpr,
        size: (4 + Math.random() * 5) * dpr,
        rot: Math.random() * Math.PI,
        vr: (Math.random() - 0.5) * 0.3,
        color: COLORS[(Math.random() * COLORS.length) | 0],
        life: 1
      });
    }
    if (!rafId) loop();
  };

  // 物理更新&描画
  const loop = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    const gravity = 0.18 * dpr;
    particles.forEach((p) => {
      p.vy += gravity;
      p.vx *= 0.99;
      p.x += p.vx;
      p.y += p.vy;
      p.rot += p.vr;
      p.life -= 0.006;
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.rot);
      ctx.globalAlpha = Math.max(0, p.life);
      ctx.fillStyle = p.color;
      ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
      ctx.restore();
    });
    // 寿命切れ・画面外を除去
    particles = particles.filter(p => p.life > 0 && p.y < canvas.height + 40);
    if (particles.length) rafId = requestAnimationFrame(loop);
    else { ctx.clearRect(0, 0, canvas.width, canvas.height); rafId = null; }
  };

  let done = false;
  btn.addEventListener('click', () => {
    if (done) return;
    done = true;
    btn.classList.add('is-done');
    const label = btn.querySelector('.cta__label');
    if (label) label.textContent = '申込完了!';
    if (!reduce) spawn(); // 抑制時は紙吹雪なし

    // デモを繰り返せるよう少し後に戻す
    setTimeout(() => {
      btn.classList.remove('is-done');
      if (label) label.textContent = 'Proを申し込む';
      done = false;
    }, 2600);
  });
})();

コード

HTML
<!-- 紙吹雪サクセスボタン: 押すとcanvasで紙吹雪が舞い、ボタンが完了状態へ -->
<div class="stage">
  <canvas class="confetti" aria-hidden="true"></canvas>
  <button class="cta" type="button">
    <span class="cta__label">購入する</span>
    <svg class="cta__check" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 12.5l5 5 11-11" />
    </svg>
  </button>
</div>
CSS
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
}

.stage {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 100vh;
}

/* 紙吹雪用キャンバスは全面に重ねるが操作は透過 */
.confetti {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* CTAボタン */
.cta {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-width: 200px;
  padding: 18px 36px;
  font-size: 17px;
  font-weight: 700;
  letter-spacing: .03em;
  color: #fff;
  border: none;
  border-radius: 14px;
  cursor: pointer;
  background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
  box-shadow: 0 14px 30px rgba(37, 117, 252, .35);
  transition: transform .15s ease, background .4s ease, box-shadow .3s ease;
  overflow: hidden;
}
.cta:active { transform: scale(.97); }

/* 完了状態: 緑に変化しチェックが出る */
.cta.is-done {
  background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
  box-shadow: 0 14px 30px rgba(56, 239, 125, .4);
  pointer-events: none;
}

.cta__label { transition: transform .3s ease, opacity .3s ease; }
.cta.is-done .cta__label {
  transform: translateY(-2px);
}

/* チェックマークは初期は隠してストローク描画 */
.cta__check {
  width: 0;
  height: 22px;
  fill: none;
  stroke: #fff;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 30;
  stroke-dashoffset: 30;
  transition: width .25s ease;
}
.cta.is-done .cta__check {
  width: 22px;
  animation: check-draw .5s ease forwards .12s;
}
@keyframes check-draw {
  to { stroke-dashoffset: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .cta, .cta__label, .cta__check { transition: none; }
  .cta.is-done .cta__check { animation: none; stroke-dashoffset: 0; }
}
JavaScript
// 紙吹雪サクセスボタン: クリックでボタンを完了状態にし、canvasで紙吹雪を物理落下
(() => {
  const btn = document.querySelector('.cta');
  const canvas = document.querySelector('.confetti');
  if (!btn || !canvas) return; // null安全

  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const COLORS = ['#6a11cb', '#2575fc', '#38ef7d', '#ffd166', '#ff5e9c', '#fff'];
  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  let particles = [];
  let rafId = null;

  // 解像度に合わせてcanvasサイズを調整
  const resize = () => {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = canvas.clientWidth * dpr;
    canvas.height = canvas.clientHeight * dpr;
  };
  resize();
  window.addEventListener('resize', resize);

  // ボタン中心付近から紙吹雪を放出
  const spawn = () => {
    const r = btn.getBoundingClientRect();
    const cr = canvas.getBoundingClientRect();
    const ox = (r.left + r.width / 2 - cr.left) * dpr;
    const oy = (r.top - cr.top) * dpr;
    const N = 90;
    for (let i = 0; i < N; i++) {
      const angle = Math.PI * (1.1 + Math.random() * 0.8); // 上方向中心に扇状
      const speed = (4 + Math.random() * 7) * dpr;
      particles.push({
        x: ox + (Math.random() - 0.5) * r.width * dpr,
        y: oy,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed - 4 * dpr,
        size: (4 + Math.random() * 5) * dpr,
        rot: Math.random() * Math.PI,
        vr: (Math.random() - 0.5) * 0.3,
        color: COLORS[(Math.random() * COLORS.length) | 0],
        life: 1
      });
    }
    if (!rafId) loop();
  };

  // 物理更新&描画ループ
  const loop = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    const gravity = 0.18 * dpr;
    particles.forEach((p) => {
      p.vy += gravity;
      p.vx *= 0.99;
      p.x += p.vx;
      p.y += p.vy;
      p.rot += p.vr;
      p.life -= 0.006;
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.rot);
      ctx.globalAlpha = Math.max(0, p.life);
      ctx.fillStyle = p.color;
      ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
      ctx.restore();
    });
    // 寿命切れ・画面外を除去
    particles = particles.filter(p => p.life > 0 && p.y < canvas.height + 40);
    if (particles.length) {
      rafId = requestAnimationFrame(loop);
    } else {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      rafId = null;
    }
  };

  let done = false;
  btn.addEventListener('click', () => {
    if (done) return;
    done = true;
    btn.classList.add('is-done');
    const label = btn.querySelector('.cta__label');
    if (label) label.textContent = '完了!';
    if (!reduce) spawn(); // モーション抑制時は紙吹雪なし

    // デモを繰り返せるよう少し後に初期状態へ戻す
    setTimeout(() => {
      btn.classList.remove('is-done');
      if (label) label.textContent = '購入する';
      done = false;
    }, 2600);
  });
})();

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

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

# 追加してほしい効果
紙吹雪サクセスボタン(マイクロインタラクション)
押すとボタンが完了状態へ変化し、canvasで紙吹雪が物理落下する達成感の演出。購入完了やフォーム送信成功のフィードバックに使えます。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 紙吹雪サクセスボタン: 押すとcanvasで紙吹雪が舞い、ボタンが完了状態へ -->
<div class="stage">
  <canvas class="confetti" aria-hidden="true"></canvas>
  <button class="cta" type="button">
    <span class="cta__label">購入する</span>
    <svg class="cta__check" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M4 12.5l5 5 11-11" />
    </svg>
  </button>
</div>

【CSS】
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
}

.stage {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 100vh;
}

/* 紙吹雪用キャンバスは全面に重ねるが操作は透過 */
.confetti {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* CTAボタン */
.cta {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-width: 200px;
  padding: 18px 36px;
  font-size: 17px;
  font-weight: 700;
  letter-spacing: .03em;
  color: #fff;
  border: none;
  border-radius: 14px;
  cursor: pointer;
  background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
  box-shadow: 0 14px 30px rgba(37, 117, 252, .35);
  transition: transform .15s ease, background .4s ease, box-shadow .3s ease;
  overflow: hidden;
}
.cta:active { transform: scale(.97); }

/* 完了状態: 緑に変化しチェックが出る */
.cta.is-done {
  background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
  box-shadow: 0 14px 30px rgba(56, 239, 125, .4);
  pointer-events: none;
}

.cta__label { transition: transform .3s ease, opacity .3s ease; }
.cta.is-done .cta__label {
  transform: translateY(-2px);
}

/* チェックマークは初期は隠してストローク描画 */
.cta__check {
  width: 0;
  height: 22px;
  fill: none;
  stroke: #fff;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 30;
  stroke-dashoffset: 30;
  transition: width .25s ease;
}
.cta.is-done .cta__check {
  width: 22px;
  animation: check-draw .5s ease forwards .12s;
}
@keyframes check-draw {
  to { stroke-dashoffset: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .cta, .cta__label, .cta__check { transition: none; }
  .cta.is-done .cta__check { animation: none; stroke-dashoffset: 0; }
}

【JavaScript】
// 紙吹雪サクセスボタン: クリックでボタンを完了状態にし、canvasで紙吹雪を物理落下
(() => {
  const btn = document.querySelector('.cta');
  const canvas = document.querySelector('.confetti');
  if (!btn || !canvas) return; // null安全

  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const COLORS = ['#6a11cb', '#2575fc', '#38ef7d', '#ffd166', '#ff5e9c', '#fff'];
  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  let particles = [];
  let rafId = null;

  // 解像度に合わせてcanvasサイズを調整
  const resize = () => {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = canvas.clientWidth * dpr;
    canvas.height = canvas.clientHeight * dpr;
  };
  resize();
  window.addEventListener('resize', resize);

  // ボタン中心付近から紙吹雪を放出
  const spawn = () => {
    const r = btn.getBoundingClientRect();
    const cr = canvas.getBoundingClientRect();
    const ox = (r.left + r.width / 2 - cr.left) * dpr;
    const oy = (r.top - cr.top) * dpr;
    const N = 90;
    for (let i = 0; i < N; i++) {
      const angle = Math.PI * (1.1 + Math.random() * 0.8); // 上方向中心に扇状
      const speed = (4 + Math.random() * 7) * dpr;
      particles.push({
        x: ox + (Math.random() - 0.5) * r.width * dpr,
        y: oy,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed - 4 * dpr,
        size: (4 + Math.random() * 5) * dpr,
        rot: Math.random() * Math.PI,
        vr: (Math.random() - 0.5) * 0.3,
        color: COLORS[(Math.random() * COLORS.length) | 0],
        life: 1
      });
    }
    if (!rafId) loop();
  };

  // 物理更新&描画ループ
  const loop = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    const gravity = 0.18 * dpr;
    particles.forEach((p) => {
      p.vy += gravity;
      p.vx *= 0.99;
      p.x += p.vx;
      p.y += p.vy;
      p.rot += p.vr;
      p.life -= 0.006;
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.rot);
      ctx.globalAlpha = Math.max(0, p.life);
      ctx.fillStyle = p.color;
      ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
      ctx.restore();
    });
    // 寿命切れ・画面外を除去
    particles = particles.filter(p => p.life > 0 && p.y < canvas.height + 40);
    if (particles.length) {
      rafId = requestAnimationFrame(loop);
    } else {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      rafId = null;
    }
  };

  let done = false;
  btn.addEventListener('click', () => {
    if (done) return;
    done = true;
    btn.classList.add('is-done');
    const label = btn.querySelector('.cta__label');
    if (label) label.textContent = '完了!';
    if (!reduce) spawn(); // モーション抑制時は紙吹雪なし

    // デモを繰り返せるよう少し後に初期状態へ戻す
    setTimeout(() => {
      btn.classList.remove('is-done');
      if (label) label.textContent = '購入する';
      done = false;
    }, 2600);
  });
})();

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

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