アニメーション・シェーダー背景

フルスクリーンの板ポリにフラグメントシェーダーで描く、時間で流れるグラデと簡易ノイズの背景。マウスで色相が微妙に変化します。ヒーローや背景演出に最適です。

外部ライブラリ: https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js

#webgl#threejs#shader#background

ライブデモ

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

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

HTML
<!-- Sakura:流れる桜色シェーダー背景のニュース/新曲告知ヒーロー -->
<section class="sk-hero" aria-label="Sakura トップ">
  <!-- 背景:時間で流れる桜色グラデ+ノイズ -->
  <canvas id="shaderbg" class="sk-hero__canvas" aria-hidden="true"></canvas>
  <div class="sk-hero__fallback" id="sk-fallback" hidden></div>

  <header class="sk-bar">
    <span class="sk-logo">🌸 Sakura</span>
    <nav class="sk-nav">
      <a href="#">メンバー</a>
      <a href="#">ライブ</a>
      <a href="#">楽曲</a>
    </nav>
  </header>

  <div class="sk-hero__body">
    <span class="sk-kicker">NEW SINGLE</span>
    <h1 class="sk-title">きみと、春の<br>まんなかで。</h1>
    <p class="sk-lead">7人組アイドルグループ Sakura、<br>4thシングルが本日配信スタート。</p>
    <div class="sk-cta">
      <button class="sk-btn" type="button">MVを見る</button>
      <span class="sk-date">2026.06.07 RELEASE</span>
    </div>
  </div>
</section>
CSS
/* Sakura:桜色シェーダー背景のヒーロー */
:root {
  --pink: #ffd1e0;
  --pink-deep: #ff8fb3;
  --white: #ffffff;
  --gray: #f3f0f2;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", system-ui, sans-serif;
  background: var(--pink);
}

.sk-hero {
  position: relative;
  width: 100%;
  height: 400px;
  overflow: hidden;
  color: #5a2b3d;
}

.sk-hero__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* フォールバック:桜色の静的グラデ */
.sk-hero__fallback {
  position: absolute;
  inset: 0;
  background: linear-gradient(135deg, #ffe3ee 0%, var(--pink) 50%, #ffc0d8 100%);
}

.sk-bar {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 26px;
}
.sk-logo {
  font-size: 18px;
  font-weight: 800;
  letter-spacing: 0.04em;
  color: #fff;
  text-shadow: 0 2px 8px rgba(214, 94, 140, 0.4);
}
.sk-nav { display: flex; gap: 20px; }
.sk-nav a {
  color: rgba(255, 255, 255, 0.95);
  text-decoration: none;
  font-size: 13px;
  font-weight: 600;
  text-shadow: 0 1px 6px rgba(214, 94, 140, 0.35);
  transition: opacity 0.2s ease;
}
.sk-nav a:hover { opacity: 0.75; }

.sk-hero__body {
  position: relative;
  z-index: 2;
  max-width: 440px;
  padding: 34px 26px;
  color: #fff;
}
.sk-kicker {
  font-size: 11px;
  letter-spacing: 0.3em;
  font-weight: 700;
  text-shadow: 0 1px 6px rgba(214, 94, 140, 0.4);
}
.sk-title {
  margin: 14px 0 14px;
  font-size: 38px;
  line-height: 1.3;
  font-weight: 800;
  text-shadow: 0 3px 16px rgba(180, 70, 110, 0.4);
}
.sk-lead {
  margin: 0 0 24px;
  font-size: 14px;
  line-height: 1.9;
  text-shadow: 0 1px 8px rgba(180, 70, 110, 0.35);
}

.sk-cta { display: flex; align-items: center; gap: 18px; }
.sk-btn {
  font: inherit;
  font-size: 14px;
  font-weight: 800;
  color: var(--pink-deep);
  background: #fff;
  border: none;
  padding: 12px 28px;
  border-radius: 999px;
  cursor: pointer;
  box-shadow: 0 8px 22px rgba(214, 94, 140, 0.4);
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.sk-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(214, 94, 140, 0.55); }
.sk-btn:active { transform: translateY(0); }
.sk-date {
  font-size: 12px;
  letter-spacing: 0.08em;
  font-weight: 600;
  text-shadow: 0 1px 6px rgba(180, 70, 110, 0.35);
}

@media (prefers-reduced-motion: reduce) {
  .sk-btn { transition: none; }
}
JavaScript
// Sakura ヒーロー:フラグメントシェーダーで流れる桜色グラデ+簡易ノイズ背景
(function () {
  "use strict";
  const canvas = document.getElementById("shaderbg");
  const fallback = document.getElementById("sk-fallback");
  // THREE未読込やcanvas不在なら安全に終了し、フォールバックを表示
  if (!canvas || typeof THREE === "undefined") {
    if (fallback) fallback.hidden = false;
    return;
  }

  const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  let renderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  } catch (e) {
    if (fallback) fallback.hidden = false;
    return;
  }
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  const scene = new THREE.Scene();
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

  const uniforms = {
    time: { value: 0 },
    resolution: { value: new THREE.Vector2(1, 1) },
    shift: { value: 0 }, // マウスで動く色みのオフセット
  };

  const vertexShader = `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = vec4(position, 1.0);
    }
  `;

  // 桜ピンク〜白〜淡ピンクの間を流れるグラデ
  const fragmentShader = `
    precision highp float;
    varying vec2 vUv;
    uniform float time;
    uniform vec2 resolution;
    uniform float shift;

    float hash(vec2 p) {
      return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
    }
    float noise(vec2 p) {
      vec2 i = floor(p);
      vec2 f = fract(p);
      vec2 u = f * f * (3.0 - 2.0 * f);
      return mix(
        mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
        mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
        u.y);
    }
    float fbm(vec2 p) {
      float v = 0.0, a = 0.5;
      for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
      return v;
    }

    void main() {
      vec2 uv = vUv;
      uv.x *= resolution.x / resolution.y;

      float t = time * 0.06;
      float n = fbm(uv * 2.0 + vec2(t, t * 0.5));
      n += 0.4 * fbm(uv * 4.0 - vec2(t * 0.6, t));

      // 桜のパレット
      vec3 deep  = vec3(1.0, 0.56, 0.70); // 濃いめの桜
      vec3 mid   = vec3(1.0, 0.82, 0.88); // 桜ピンク
      vec3 light = vec3(1.0, 0.96, 0.98); // ほぼ白

      float m = clamp(n + 0.25 * sin(vUv.y * 3.0 + time * 0.2) + shift, 0.0, 1.0);
      vec3 col = mix(deep, mid, smoothstep(0.0, 0.55, m));
      col = mix(col, light, smoothstep(0.55, 1.0, m));

      // 上部をやや明るく、奥行きを出す
      col += 0.06 * (1.0 - vUv.y);
      gl_FragColor = vec4(col, 1.0);
    }
  `;

  const material = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader });
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
  scene.add(mesh);

  // マウスX位置で色みをわずかに動かす
  let targetShift = 0;
  canvas.addEventListener("pointermove", (e) => {
    const r = canvas.getBoundingClientRect();
    targetShift = ((e.clientX - r.left) / (r.width || 1) - 0.5) * 0.3;
  });

  function resize() {
    const w = canvas.clientWidth || 1;
    const h = canvas.clientHeight || 1;
    renderer.setSize(w, h, false);
    uniforms.resolution.value.set(w, h);
  }
  resize();
  window.addEventListener("resize", resize);

  let raf = 0;
  let running = true;
  const start = performance.now();
  function animate(now) {
    const t = (now - start) * 0.001;
    if (!reduceMotion) uniforms.time.value = t;
    uniforms.shift.value += (targetShift - uniforms.shift.value) * 0.05;
    renderer.render(scene, camera);
    raf = requestAnimationFrame(animate);
  }
  animate(start);

  document.addEventListener("visibilitychange", () => {
    if (document.hidden) {
      if (running) { cancelAnimationFrame(raf); running = false; }
    } else if (!running) {
      running = true;
      raf = requestAnimationFrame(animate);
    }
  });

  window.addEventListener("beforeunload", () => cancelAnimationFrame(raf));
})();

コード

HTML
<!-- フラグメントシェーダーで描く流れるグラデ背景 -->
<div class="stage">
  <canvas id="shaderbg" aria-label="アニメーション・シェーダー背景"></canvas>
  <div class="fallback" id="sg-fallback" hidden>WebGL を表示できない環境です</div>
  <div class="caption">
    <span class="badge">Shader</span>
    <h2>Animated Gradient</h2>
    <p>時間で流れる手続き的グラデ・マウスで色相が変化</p>
  </div>
</div>
CSS
/* 配色変数 */
:root {
  --ink: #f3f6ff;
  --accent: #ffd1a8;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: "Segoe UI", system-ui, sans-serif;
  overflow: hidden;
}

.stage {
  position: relative;
  width: 100%;
  height: 360px;
  /* シェーダー未対応時の控えめな下地 */
  background: linear-gradient(135deg, #2a1a4a 0%, #6a3d8c 50%, #c46a8c 100%);
}

#shaderbg {
  display: block;
  width: 100%;
  height: 100%;
}

/* WebGL非対応時のフォールバック表示 */
.fallback {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ink);
  font-size: 14px;
  letter-spacing: .06em;
  text-shadow: 0 2px 10px rgba(0, 0, 0, .6);
}
/* hidden 属性を尊重(CSSの display 指定が [hidden] を上書きしないように) */
.fallback[hidden] { display: none; }

.caption {
  position: absolute;
  left: 28px;
  bottom: 24px;
  color: var(--ink);
  text-shadow: 0 2px 14px rgba(0, 0, 0, .55);
  pointer-events: none;
}

.badge {
  display: inline-block;
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgba(255, 209, 168, .18);
  border: 1px solid rgba(255, 209, 168, .5);
  color: var(--accent);
  margin-bottom: 10px;
}

.caption h2 {
  font-size: 22px;
  font-weight: 700;
}

.caption p {
  margin-top: 4px;
  font-size: 13px;
  opacity: .8;
}
JavaScript
// アニメーション・シェーダー背景:板ポリ+フラグメントシェーダーで流れるグラデ+簡易ノイズ
(function () {
  "use strict";
  const canvas = document.getElementById("shaderbg");
  const fallback = document.getElementById("sg-fallback");
  // THREE未読込やcanvas不在なら安全に終了し、フォールバックを表示
  if (!canvas || typeof THREE === "undefined") {
    if (fallback) fallback.hidden = false;
    return;
  }

  const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  // WebGL初期化(失敗時はフォールバック)
  let renderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  } catch (e) {
    if (fallback) fallback.hidden = false;
    return;
  }
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  const scene = new THREE.Scene();
  // スクリーン全面を覆う正射影カメラ
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

  // フラグメントシェーダー:流れるグラデ+valueノイズ+色相シフト
  const uniforms = {
    time: { value: 0 },
    resolution: { value: new THREE.Vector2(1, 1) },
    hue: { value: 0 }, // マウスで動く色相オフセット
  };

  const vertexShader = `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = vec4(position, 1.0);
    }
  `;

  const fragmentShader = `
    precision highp float;
    varying vec2 vUv;
    uniform float time;
    uniform vec2 resolution;
    uniform float hue;

    // 擬似乱数とvalueノイズ
    float hash(vec2 p) {
      return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
    }
    float noise(vec2 p) {
      vec2 i = floor(p);
      vec2 f = fract(p);
      vec2 u = f * f * (3.0 - 2.0 * f); // スムーズ補間
      return mix(
        mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
        mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
        u.y);
    }
    // 重ね合わせで雲状のうねりを作る
    float fbm(vec2 p) {
      float v = 0.0, a = 0.5;
      for (int i = 0; i < 4; i++) {
        v += a * noise(p);
        p *= 2.0;
        a *= 0.5;
      }
      return v;
    }
    // HSV → RGB
    vec3 hsv2rgb(vec3 c) {
      vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
      vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
      return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
    }

    void main() {
      // アスペクト比を補正したUV
      vec2 uv = vUv;
      uv.x *= resolution.x / resolution.y;

      float t = time * 0.08;
      // 時間で流れるノイズ場
      float n = fbm(uv * 2.2 + vec2(t, t * 0.6));
      n += 0.4 * fbm(uv * 4.0 - vec2(t * 0.7, t));

      // 流れる色相(位置+時間+ノイズ+マウス)
      float h = fract(0.6 + uv.x * 0.15 + uv.y * 0.1 + n * 0.35 + time * 0.01 + hue);
      float s = 0.55 + 0.2 * n;
      float v = 0.55 + 0.4 * n;
      vec3 col = hsv2rgb(vec3(h, s, v));

      // 中央をわずかに明るくして奥行きを出す
      float d = distance(vUv, vec2(0.5));
      col *= 1.0 - 0.35 * d;

      gl_FragColor = vec4(col, 1.0);
    }
  `;

  const material = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader });
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
  scene.add(mesh);

  // マウスのX位置で色相オフセットを少し動かす
  let targetHue = 0;
  canvas.addEventListener("pointermove", (e) => {
    const r = canvas.getBoundingClientRect();
    targetHue = ((e.clientX - r.left) / (r.width || 1) - 0.5) * 0.4;
  });

  function resize() {
    const w = canvas.clientWidth || 1;
    const h = canvas.clientHeight || 1;
    renderer.setSize(w, h, false);
    uniforms.resolution.value.set(w, h);
  }
  resize();
  window.addEventListener("resize", resize);

  let raf = 0;
  let running = true;
  const start = performance.now();
  function animate(now) {
    const t = (now - start) * 0.001;
    if (!reduceMotion) uniforms.time.value = t;
    // 色相をマウス方向へイージング
    uniforms.hue.value += (targetHue - uniforms.hue.value) * 0.05;
    renderer.render(scene, camera);
    raf = requestAnimationFrame(animate);
  }
  animate(start);

  // タブ非表示で停止、復帰で再開
  document.addEventListener("visibilitychange", () => {
    if (document.hidden) {
      if (running) { cancelAnimationFrame(raf); running = false; }
    } else if (!running) {
      running = true;
      raf = requestAnimationFrame(animate);
    }
  });

  window.addEventListener("beforeunload", () => cancelAnimationFrame(raf));
})();

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

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

# 追加してほしい効果
アニメーション・シェーダー背景(WebGL / Three.js)
フルスクリーンの板ポリにフラグメントシェーダーで描く、時間で流れるグラデと簡易ノイズの背景。マウスで色相が微妙に変化します。ヒーローや背景演出に最適です。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- フラグメントシェーダーで描く流れるグラデ背景 -->
<div class="stage">
  <canvas id="shaderbg" aria-label="アニメーション・シェーダー背景"></canvas>
  <div class="fallback" id="sg-fallback" hidden>WebGL を表示できない環境です</div>
  <div class="caption">
    <span class="badge">Shader</span>
    <h2>Animated Gradient</h2>
    <p>時間で流れる手続き的グラデ・マウスで色相が変化</p>
  </div>
</div>

【CSS】
/* 配色変数 */
:root {
  --ink: #f3f6ff;
  --accent: #ffd1a8;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: "Segoe UI", system-ui, sans-serif;
  overflow: hidden;
}

.stage {
  position: relative;
  width: 100%;
  height: 360px;
  /* シェーダー未対応時の控えめな下地 */
  background: linear-gradient(135deg, #2a1a4a 0%, #6a3d8c 50%, #c46a8c 100%);
}

#shaderbg {
  display: block;
  width: 100%;
  height: 100%;
}

/* WebGL非対応時のフォールバック表示 */
.fallback {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ink);
  font-size: 14px;
  letter-spacing: .06em;
  text-shadow: 0 2px 10px rgba(0, 0, 0, .6);
}
/* hidden 属性を尊重(CSSの display 指定が [hidden] を上書きしないように) */
.fallback[hidden] { display: none; }

.caption {
  position: absolute;
  left: 28px;
  bottom: 24px;
  color: var(--ink);
  text-shadow: 0 2px 14px rgba(0, 0, 0, .55);
  pointer-events: none;
}

.badge {
  display: inline-block;
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgba(255, 209, 168, .18);
  border: 1px solid rgba(255, 209, 168, .5);
  color: var(--accent);
  margin-bottom: 10px;
}

.caption h2 {
  font-size: 22px;
  font-weight: 700;
}

.caption p {
  margin-top: 4px;
  font-size: 13px;
  opacity: .8;
}

【JavaScript】
// アニメーション・シェーダー背景:板ポリ+フラグメントシェーダーで流れるグラデ+簡易ノイズ
(function () {
  "use strict";
  const canvas = document.getElementById("shaderbg");
  const fallback = document.getElementById("sg-fallback");
  // THREE未読込やcanvas不在なら安全に終了し、フォールバックを表示
  if (!canvas || typeof THREE === "undefined") {
    if (fallback) fallback.hidden = false;
    return;
  }

  const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  // WebGL初期化(失敗時はフォールバック)
  let renderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  } catch (e) {
    if (fallback) fallback.hidden = false;
    return;
  }
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  const scene = new THREE.Scene();
  // スクリーン全面を覆う正射影カメラ
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

  // フラグメントシェーダー:流れるグラデ+valueノイズ+色相シフト
  const uniforms = {
    time: { value: 0 },
    resolution: { value: new THREE.Vector2(1, 1) },
    hue: { value: 0 }, // マウスで動く色相オフセット
  };

  const vertexShader = `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = vec4(position, 1.0);
    }
  `;

  const fragmentShader = `
    precision highp float;
    varying vec2 vUv;
    uniform float time;
    uniform vec2 resolution;
    uniform float hue;

    // 擬似乱数とvalueノイズ
    float hash(vec2 p) {
      return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
    }
    float noise(vec2 p) {
      vec2 i = floor(p);
      vec2 f = fract(p);
      vec2 u = f * f * (3.0 - 2.0 * f); // スムーズ補間
      return mix(
        mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
        mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
        u.y);
    }
    // 重ね合わせで雲状のうねりを作る
    float fbm(vec2 p) {
      float v = 0.0, a = 0.5;
      for (int i = 0; i < 4; i++) {
        v += a * noise(p);
        p *= 2.0;
        a *= 0.5;
      }
      return v;
    }
    // HSV → RGB
    vec3 hsv2rgb(vec3 c) {
      vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
      vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
      return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
    }

    void main() {
      // アスペクト比を補正したUV
      vec2 uv = vUv;
      uv.x *= resolution.x / resolution.y;

      float t = time * 0.08;
      // 時間で流れるノイズ場
      float n = fbm(uv * 2.2 + vec2(t, t * 0.6));
      n += 0.4 * fbm(uv * 4.0 - vec2(t * 0.7, t));

      // 流れる色相(位置+時間+ノイズ+マウス)
      float h = fract(0.6 + uv.x * 0.15 + uv.y * 0.1 + n * 0.35 + time * 0.01 + hue);
      float s = 0.55 + 0.2 * n;
      float v = 0.55 + 0.4 * n;
      vec3 col = hsv2rgb(vec3(h, s, v));

      // 中央をわずかに明るくして奥行きを出す
      float d = distance(vUv, vec2(0.5));
      col *= 1.0 - 0.35 * d;

      gl_FragColor = vec4(col, 1.0);
    }
  `;

  const material = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader });
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
  scene.add(mesh);

  // マウスのX位置で色相オフセットを少し動かす
  let targetHue = 0;
  canvas.addEventListener("pointermove", (e) => {
    const r = canvas.getBoundingClientRect();
    targetHue = ((e.clientX - r.left) / (r.width || 1) - 0.5) * 0.4;
  });

  function resize() {
    const w = canvas.clientWidth || 1;
    const h = canvas.clientHeight || 1;
    renderer.setSize(w, h, false);
    uniforms.resolution.value.set(w, h);
  }
  resize();
  window.addEventListener("resize", resize);

  let raf = 0;
  let running = true;
  const start = performance.now();
  function animate(now) {
    const t = (now - start) * 0.001;
    if (!reduceMotion) uniforms.time.value = t;
    // 色相をマウス方向へイージング
    uniforms.hue.value += (targetHue - uniforms.hue.value) * 0.05;
    renderer.render(scene, camera);
    raf = requestAnimationFrame(animate);
  }
  animate(start);

  // タブ非表示で停止、復帰で再開
  document.addEventListener("visibilitychange", () => {
    if (document.hidden) {
      if (running) { cancelAnimationFrame(raf); running = false; }
    } else if (!running) {
      running = true;
      raf = requestAnimationFrame(animate);
    }
  });

  window.addEventListener("beforeunload", () => cancelAnimationFrame(raf));
})();

# 外部ライブラリ
https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js

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