リアルタイム波形

Canvasで合成正弦波を毎フレーム描画するオシロスコープ風モニタ。グロー発光と一時停止操作で、信号やストリーミングデータの可視化に。

#canvas#realtime#animation#interaction

ライブデモ

使用例(お題: アイドルグループ Sakura)

この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- Sakura:ライブ配信画面。会場の歓声レベルをリアルタイム波形で可視化 -->
<section class="sw-stage">
  <header class="sw-head">
    <div class="sw-brand"><span class="sw-petal"></span> Sakura</div>
    <div class="sw-live">
      <span class="sw-led" id="swLed"></span>
      <span class="sw-live__txt">LIVE 配信中</span>
    </div>
  </header>

  <div class="sw-monitor">
    <div class="sw-monitor__top">
      <p class="sw-monitor__title">会場 盛り上がりメーター</p>
      <span class="sw-hz" id="swHz">42.0 Hz</span>
    </div>
    <!-- リアルタイム波形(Canvasで合成正弦波を毎フレーム描画) -->
    <canvas id="swWaveCanvas" class="sw-canvas" role="img" aria-label="会場の歓声波形"></canvas>
  </div>

  <footer class="sw-foot">
    <span class="sw-now">♪ 再生中:「春風センセーション」</span>
    <button class="sw-pause" id="swWavePause" type="button" aria-pressed="false">一時停止</button>
  </footer>
</section>
CSS
/* Sakura:ライブ盛り上がりメーター(リアルタイム波形が主役) */
* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  font-family: "Yu Gothic", "Hiragino Kaku Gothic ProN", "Segoe UI", sans-serif;
  background:
    radial-gradient(600px 360px at 50% 18%, #3a1a2a 0%, transparent 65%),
    linear-gradient(165deg, #1a0e16 0%, #12080f 100%);
  color: #ffd1e0;
}

.sw-stage { height: 400px; padding: 18px 22px; display: flex; flex-direction: column; }

.sw-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.sw-brand { display: flex; align-items: center; gap: 9px; font-weight: 800; font-size: 16px; color: #ffd1e0; }
.sw-petal {
  width: 14px; height: 14px; background: #ffd1e0;
  border-radius: 50% 0 50% 50%; transform: rotate(45deg);
  box-shadow: 0 0 10px #ff8fb5;
}
.sw-live { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 700; }
.sw-live__txt { color: #ff8fb5; letter-spacing: 0.08em; }
/* 録画中ランプ(点滅)。一時停止で消灯 */
.sw-led {
  width: 9px; height: 9px; border-radius: 50%;
  background: #ff5e98; box-shadow: 0 0 8px #ff5e98;
  animation: swBlink 1.1s infinite;
}
.sw-led.paused { background: #6b5560; box-shadow: none; animation: none; }
@keyframes swBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }

/* 波形モニタ */
.sw-monitor {
  flex: 1;
  background: rgba(20,10,18,0.6);
  border: 1px solid rgba(255,143,181,0.22);
  border-radius: 14px;
  padding: 12px 14px 8px;
  display: flex; flex-direction: column;
  box-shadow: inset 0 0 40px rgba(255,94,152,0.06);
}
.sw-monitor__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.sw-monitor__title { margin: 0; font-size: 12px; color: #e0a8bd; letter-spacing: 0.06em; }
.sw-hz {
  font-size: 12px; font-weight: 700; color: #ff8fb5;
  font-variant-numeric: tabular-nums; letter-spacing: 0.04em;
}
.sw-canvas { flex: 1; width: 100%; min-height: 0; display: block; }

.sw-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; }
.sw-now { font-size: 12px; color: #e0a8bd; }
.sw-pause {
  font: inherit; font-size: 12px; font-weight: 700;
  padding: 8px 18px; border-radius: 20px; cursor: pointer;
  color: #fff0f6; background: transparent;
  border: 1.5px solid #ff5e98;
  box-shadow: 0 0 12px rgba(255,94,152,0.4), inset 0 0 8px rgba(255,94,152,0.15);
  transition: background 0.2s ease;
}
.sw-pause:hover { background: rgba(255,94,152,0.15); }
JavaScript
// Sakura:会場の歓声レベルを合成波形でリアルタイム描画(グロー・一時停止)
(() => {
  const canvas = document.getElementById('swWaveCanvas');
  const pauseBtn = document.getElementById('swWavePause');
  const led = document.getElementById('swLed');
  const hzEl = document.getElementById('swHz');
  if (!canvas) return; // null安全
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  let dpr = 1, W = 0, H = 0;
  function resize() {
    dpr = Math.max(1, window.devicePixelRatio || 1);
    const rect = canvas.getBoundingClientRect();
    W = rect.width; H = rect.height;
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  resize();
  window.addEventListener('resize', resize);

  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let phase = 0;
  let running = !reduceMotion; // reduced-motionなら静止波形
  let lastTime = performance.now();

  // 複数正弦波を合成して有機的な歓声波形を作る
  function sample(x, t) {
    const k = x * 0.018;
    return (
      Math.sin(k + t * 2.0) * 0.5 +
      Math.sin(k * 2.3 + t * 1.3) * 0.28 +
      Math.sin(k * 0.6 - t * 0.8) * 0.18
    );
  }

  function drawGrid() {
    ctx.strokeStyle = 'rgba(255, 143, 181, 0.1)';
    ctx.lineWidth = 1;
    const gx = 28, gy = 24;
    for (let x = 0; x <= W; x += gx) {
      ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
    }
    for (let y = 0; y <= H; y += gy) {
      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
    }
    // 中央基準線を強調
    ctx.strokeStyle = 'rgba(255, 143, 181, 0.22)';
    ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
  }

  function drawWave(t) {
    const mid = H / 2;
    const amp = H * 0.34;
    ctx.beginPath();
    for (let x = 0; x <= W; x += 2) {
      const y = mid + sample(x, t) * amp;
      if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
    }
    // 桜ピンクのグロー付き発光ライン
    ctx.lineJoin = 'round';
    ctx.strokeStyle = 'rgba(255, 94, 152, 0.35)';
    ctx.lineWidth = 6;
    ctx.shadowColor = '#ff5e98';
    ctx.shadowBlur = 14;
    ctx.stroke();
    ctx.strokeStyle = '#ffd1e0';
    ctx.lineWidth = 2;
    ctx.shadowBlur = 6;
    ctx.stroke();
    ctx.shadowBlur = 0;
  }

  function frame(now) {
    const dt = Math.min(0.05, (now - lastTime) / 1000);
    lastTime = now;
    if (running) phase += dt;

    ctx.clearRect(0, 0, W, H);
    drawGrid();
    drawWave(phase);

    // 擬似的な周波数表示(位相速度から算出)
    if (hzEl) {
      const hz = (42 + Math.sin(phase * 0.5) * 6).toFixed(1);
      hzEl.textContent = `${hz} Hz`;
    }
    requestAnimationFrame(frame);
  }

  // 一時停止トグル
  if (pauseBtn) {
    pauseBtn.addEventListener('click', () => {
      running = !running;
      pauseBtn.textContent = running ? '一時停止' : '再開';
      pauseBtn.setAttribute('aria-pressed', String(!running));
      if (led) led.classList.toggle('paused', !running);
    });
  }
  if (reduceMotion && pauseBtn) {
    pauseBtn.textContent = '再開';
    pauseBtn.setAttribute('aria-pressed', 'true');
    if (led) led.classList.add('paused');
  }

  requestAnimationFrame(frame);
})();

コード

HTML
<div class="dv-wrap">
  <figure class="dv-card">
    <figcaption class="dv-head">
      <h2 class="dv-title">リアルタイム信号モニタ</h2>
      <p class="dv-sub">Canvasで毎フレーム流れる波形(オシロスコープ風)</p>
    </figcaption>
    <div class="dv-screen">
      <canvas id="waveCanvas" class="dv-canvas" role="img" aria-label="リアルタイムに流れる波形"></canvas>
      <div class="dv-readout">
        <span class="dv-led" id="waveLed"></span>
        <span id="waveHz" class="dv-hz">— Hz</span>
      </div>
    </div>
    <div class="dv-ctrl">
      <button id="wavePause" class="dv-btn" type="button" aria-pressed="false">一時停止</button>
    </div>
  </figure>
</div>
CSS
:root {
  --dv-radius: 18px;
  --dv-ink: #d1fae5;
  --dv-sub: #6ee7b7;
  --dv-screen: #021410;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  color: var(--dv-ink);
  background:
    radial-gradient(800px 500px at 50% -10%, #064e3b 0%, transparent 55%),
    linear-gradient(160deg, #03251c, #010d0a);
}

.dv-wrap { width: min(92vw, 720px); padding: 20px; }

.dv-card {
  margin: 0;
  padding: 20px 22px 18px;
  border-radius: var(--dv-radius);
  background: rgba(2, 20, 16, 0.6);
  border: 1px solid rgba(16, 185, 129, 0.25);
  box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.8);
}

.dv-head { margin-bottom: 12px; }
.dv-title { margin: 0; font-size: clamp(18px, 3.4vw, 22px); }
.dv-sub { margin: 4px 0 0; font-size: 13px; color: var(--dv-sub); }

.dv-screen {
  position: relative;
  border-radius: 12px;
  overflow: hidden;
  background: var(--dv-screen);
  box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(16, 185, 129, 0.2);
}

.dv-canvas { display: block; width: 100%; height: 176px; }

.dv-readout {
  position: absolute;
  top: 10px;
  right: 12px;
  display: flex;
  align-items: center;
  gap: 8px;
  font-variant-numeric: tabular-nums;
  font-size: 13px;
  font-weight: 700;
  color: #6ee7b7;
  text-shadow: 0 0 8px rgba(110, 231, 183, 0.6);
}

.dv-led {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: #34d399;
  box-shadow: 0 0 10px #34d399;
  animation: dv-blink 1s steps(2, jump-none) infinite;
}
.dv-led.paused { background: #fbbf24; box-shadow: 0 0 10px #fbbf24; animation: none; }

@keyframes dv-blink { 50% { opacity: 0.25; } }

.dv-ctrl { margin-top: 12px; text-align: center; }

.dv-btn {
  padding: 9px 22px;
  border: 1px solid rgba(16, 185, 129, 0.45);
  border-radius: 999px;
  background: rgba(16, 185, 129, 0.12);
  color: var(--dv-ink);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: background .2s ease, transform .1s ease;
}
.dv-btn:hover { background: rgba(16, 185, 129, 0.25); }
.dv-btn:active { transform: scale(0.96); }
.dv-btn:focus-visible { outline: 2px solid #6ee7b7; outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .dv-led { animation: none; }
}
JavaScript
// Canvasで流れる合成波形をリアルタイム描画(グリッド・グロー・周波数表示付き)
(() => {
  const canvas = document.getElementById('waveCanvas');
  const pauseBtn = document.getElementById('wavePause');
  const led = document.getElementById('waveLed');
  const hzEl = document.getElementById('waveHz');
  if (!canvas) return; // null安全
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  let dpr = 1, W = 0, H = 0;
  function resize() {
    dpr = Math.max(1, window.devicePixelRatio || 1);
    const rect = canvas.getBoundingClientRect();
    W = rect.width; H = rect.height;
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  resize();
  window.addEventListener('resize', resize);

  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let phase = 0;
  let running = !reduceMotion; // reduced-motionなら静止波形
  let lastTime = performance.now();

  // 複数の正弦波を合成して有機的な波形を作る
  function sample(x, t) {
    const k = x * 0.018;
    return (
      Math.sin(k + t * 2.0) * 0.5 +
      Math.sin(k * 2.3 + t * 1.3) * 0.28 +
      Math.sin(k * 0.6 - t * 0.8) * 0.18
    );
  }

  function drawGrid() {
    ctx.strokeStyle = 'rgba(16, 185, 129, 0.12)';
    ctx.lineWidth = 1;
    const gx = 28, gy = 24;
    for (let x = 0; x <= W; x += gx) {
      ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
    }
    for (let y = 0; y <= H; y += gy) {
      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
    }
    // 中央基準線を強調
    ctx.strokeStyle = 'rgba(16, 185, 129, 0.25)';
    ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
  }

  function drawWave(t) {
    const mid = H / 2;
    const amp = H * 0.34;
    ctx.beginPath();
    for (let x = 0; x <= W; x += 2) {
      const y = mid + sample(x, t) * amp;
      if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
    }
    // グロー付きの発光ライン
    ctx.lineJoin = 'round';
    ctx.strokeStyle = 'rgba(52, 211, 153, 0.35)';
    ctx.lineWidth = 6;
    ctx.shadowColor = '#34d399';
    ctx.shadowBlur = 14;
    ctx.stroke();
    ctx.strokeStyle = '#6ee7b7';
    ctx.lineWidth = 2;
    ctx.shadowBlur = 6;
    ctx.stroke();
    ctx.shadowBlur = 0;
  }

  function frame(now) {
    const dt = Math.min(0.05, (now - lastTime) / 1000);
    lastTime = now;
    if (running) phase += dt;

    ctx.clearRect(0, 0, W, H);
    drawGrid();
    drawWave(phase);

    // 擬似的な周波数表示(位相速度から算出)
    if (hzEl) {
      const hz = (42 + Math.sin(phase * 0.5) * 6).toFixed(1);
      hzEl.textContent = `${hz} Hz`;
    }
    requestAnimationFrame(frame);
  }

  // 一時停止トグル
  if (pauseBtn) {
    pauseBtn.addEventListener('click', () => {
      running = !running;
      pauseBtn.textContent = running ? '一時停止' : '再開';
      pauseBtn.setAttribute('aria-pressed', String(!running));
      if (led) led.classList.toggle('paused', !running);
    });
  }
  if (reduceMotion && pauseBtn) {
    pauseBtn.textContent = '再開';
    pauseBtn.setAttribute('aria-pressed', 'true');
    if (led) led.classList.add('paused');
  }

  requestAnimationFrame(frame);
})();

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

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

# 追加してほしい効果
リアルタイム波形(データ可視化)
Canvasで合成正弦波を毎フレーム描画するオシロスコープ風モニタ。グロー発光と一時停止操作で、信号やストリーミングデータの可視化に。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<div class="dv-wrap">
  <figure class="dv-card">
    <figcaption class="dv-head">
      <h2 class="dv-title">リアルタイム信号モニタ</h2>
      <p class="dv-sub">Canvasで毎フレーム流れる波形(オシロスコープ風)</p>
    </figcaption>
    <div class="dv-screen">
      <canvas id="waveCanvas" class="dv-canvas" role="img" aria-label="リアルタイムに流れる波形"></canvas>
      <div class="dv-readout">
        <span class="dv-led" id="waveLed"></span>
        <span id="waveHz" class="dv-hz">— Hz</span>
      </div>
    </div>
    <div class="dv-ctrl">
      <button id="wavePause" class="dv-btn" type="button" aria-pressed="false">一時停止</button>
    </div>
  </figure>
</div>

【CSS】
:root {
  --dv-radius: 18px;
  --dv-ink: #d1fae5;
  --dv-sub: #6ee7b7;
  --dv-screen: #021410;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  color: var(--dv-ink);
  background:
    radial-gradient(800px 500px at 50% -10%, #064e3b 0%, transparent 55%),
    linear-gradient(160deg, #03251c, #010d0a);
}

.dv-wrap { width: min(92vw, 720px); padding: 20px; }

.dv-card {
  margin: 0;
  padding: 20px 22px 18px;
  border-radius: var(--dv-radius);
  background: rgba(2, 20, 16, 0.6);
  border: 1px solid rgba(16, 185, 129, 0.25);
  box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.8);
}

.dv-head { margin-bottom: 12px; }
.dv-title { margin: 0; font-size: clamp(18px, 3.4vw, 22px); }
.dv-sub { margin: 4px 0 0; font-size: 13px; color: var(--dv-sub); }

.dv-screen {
  position: relative;
  border-radius: 12px;
  overflow: hidden;
  background: var(--dv-screen);
  box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(16, 185, 129, 0.2);
}

.dv-canvas { display: block; width: 100%; height: 176px; }

.dv-readout {
  position: absolute;
  top: 10px;
  right: 12px;
  display: flex;
  align-items: center;
  gap: 8px;
  font-variant-numeric: tabular-nums;
  font-size: 13px;
  font-weight: 700;
  color: #6ee7b7;
  text-shadow: 0 0 8px rgba(110, 231, 183, 0.6);
}

.dv-led {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: #34d399;
  box-shadow: 0 0 10px #34d399;
  animation: dv-blink 1s steps(2, jump-none) infinite;
}
.dv-led.paused { background: #fbbf24; box-shadow: 0 0 10px #fbbf24; animation: none; }

@keyframes dv-blink { 50% { opacity: 0.25; } }

.dv-ctrl { margin-top: 12px; text-align: center; }

.dv-btn {
  padding: 9px 22px;
  border: 1px solid rgba(16, 185, 129, 0.45);
  border-radius: 999px;
  background: rgba(16, 185, 129, 0.12);
  color: var(--dv-ink);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: background .2s ease, transform .1s ease;
}
.dv-btn:hover { background: rgba(16, 185, 129, 0.25); }
.dv-btn:active { transform: scale(0.96); }
.dv-btn:focus-visible { outline: 2px solid #6ee7b7; outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .dv-led { animation: none; }
}

【JavaScript】
// Canvasで流れる合成波形をリアルタイム描画(グリッド・グロー・周波数表示付き)
(() => {
  const canvas = document.getElementById('waveCanvas');
  const pauseBtn = document.getElementById('wavePause');
  const led = document.getElementById('waveLed');
  const hzEl = document.getElementById('waveHz');
  if (!canvas) return; // null安全
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  let dpr = 1, W = 0, H = 0;
  function resize() {
    dpr = Math.max(1, window.devicePixelRatio || 1);
    const rect = canvas.getBoundingClientRect();
    W = rect.width; H = rect.height;
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  resize();
  window.addEventListener('resize', resize);

  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let phase = 0;
  let running = !reduceMotion; // reduced-motionなら静止波形
  let lastTime = performance.now();

  // 複数の正弦波を合成して有機的な波形を作る
  function sample(x, t) {
    const k = x * 0.018;
    return (
      Math.sin(k + t * 2.0) * 0.5 +
      Math.sin(k * 2.3 + t * 1.3) * 0.28 +
      Math.sin(k * 0.6 - t * 0.8) * 0.18
    );
  }

  function drawGrid() {
    ctx.strokeStyle = 'rgba(16, 185, 129, 0.12)';
    ctx.lineWidth = 1;
    const gx = 28, gy = 24;
    for (let x = 0; x <= W; x += gx) {
      ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
    }
    for (let y = 0; y <= H; y += gy) {
      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
    }
    // 中央基準線を強調
    ctx.strokeStyle = 'rgba(16, 185, 129, 0.25)';
    ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
  }

  function drawWave(t) {
    const mid = H / 2;
    const amp = H * 0.34;
    ctx.beginPath();
    for (let x = 0; x <= W; x += 2) {
      const y = mid + sample(x, t) * amp;
      if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
    }
    // グロー付きの発光ライン
    ctx.lineJoin = 'round';
    ctx.strokeStyle = 'rgba(52, 211, 153, 0.35)';
    ctx.lineWidth = 6;
    ctx.shadowColor = '#34d399';
    ctx.shadowBlur = 14;
    ctx.stroke();
    ctx.strokeStyle = '#6ee7b7';
    ctx.lineWidth = 2;
    ctx.shadowBlur = 6;
    ctx.stroke();
    ctx.shadowBlur = 0;
  }

  function frame(now) {
    const dt = Math.min(0.05, (now - lastTime) / 1000);
    lastTime = now;
    if (running) phase += dt;

    ctx.clearRect(0, 0, W, H);
    drawGrid();
    drawWave(phase);

    // 擬似的な周波数表示(位相速度から算出)
    if (hzEl) {
      const hz = (42 + Math.sin(phase * 0.5) * 6).toFixed(1);
      hzEl.textContent = `${hz} Hz`;
    }
    requestAnimationFrame(frame);
  }

  // 一時停止トグル
  if (pauseBtn) {
    pauseBtn.addEventListener('click', () => {
      running = !running;
      pauseBtn.textContent = running ? '一時停止' : '再開';
      pauseBtn.setAttribute('aria-pressed', String(!running));
      if (led) led.classList.toggle('paused', !running);
    });
  }
  if (reduceMotion && pauseBtn) {
    pauseBtn.textContent = '再開';
    pauseBtn.setAttribute('aria-pressed', 'true');
    if (led) led.classList.add('paused');
  }

  requestAnimationFrame(frame);
})();

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

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