このページの中身

蛍

背景エフェクト / 動く素材トップ

点が明滅しながら漂い、近づくほど強く光る。

試して調整する#

見た目を調整する

元の実装の既定値で動いています

判断#

向く場面: 夏・幻想。暗い地に小さな光を漂わせたいとき。

避ける場面: 長文の背面や、写真そのものを主役にする画面。

つまみは元の実装が持っている項目そのままです。実際に文字を重ねた状態で速さと密度を詰めます。

組み込み条件#

依存: 素のCanvas 2D。外部ライブラリは要らない。

負荷: 1画面に1つ。画面外・非表示タブ・動きを減らす設定で止まる。

再利用: コピーした単一HTMLがそのまま動く。

単一HTMLをコピーする#

下のコードだけで実物サンプルと同じ画面が動きます。調整欄を動かすと、値を反映したコードへ更新されます。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,follow">
<title>蛍 実物サンプル</title>
<style>
:root {
  color-scheme: light dark;
  --ink-light:#1a2421; --mut-light:#5a6a72; --signal-light:#2a7ab8; --bg-light:#f4f8fb;
  --ink-dark:#f4f8fb; --mut-dark:#a8bac7; --signal-dark:#48b8ff; --bg-dark:#07111f;
}
* { box-sizing:border-box; }
html,body { margin:0; min-height:100%; }
body { font-family:"Hiragino Sans","Yu Gothic UI",system-ui,sans-serif; transition:background .3s,color .3s; }
html[data-demo-theme="light"], html[data-demo-theme="light"] body {
  background:var(--bg-light); color:var(--ink-light); --ink:var(--ink-light); --mut:var(--mut-light); --signal:var(--signal-light); }
html[data-demo-theme="dark"], html[data-demo-theme="dark"] body {
  background:var(--bg-dark); color:var(--ink-dark); --ink:var(--ink-dark); --mut:var(--mut-dark); --signal:var(--signal-dark); }
@media (prefers-color-scheme:light) {
  html:not([data-demo-theme]), html:not([data-demo-theme]) body {
    background:var(--bg-light); color:var(--ink-light); --ink:var(--ink-light); --mut:var(--mut-light); --signal:var(--signal-light); } }
@media (prefers-color-scheme:dark) {
  html:not([data-demo-theme]), html:not([data-demo-theme]) body {
    background:var(--bg-dark); color:var(--ink-dark); --ink:var(--ink-dark); --mut:var(--mut-dark); --signal:var(--signal-dark); } }
.sample { position:relative; min-height:100dvh; overflow:hidden; isolation:isolate; display:grid; align-items:end; }
.effect { position:absolute; inset:0; z-index:0; }
.effect canvas { display:block; width:100%; height:100%; touch-action:manipulation; }
.copy { position:relative; z-index:1; width:min(42rem,100%); padding:clamp(2rem,7vw,6rem); }
.label { margin:0 0 1rem; color:var(--signal); font:600 .72rem/1.4 ui-monospace,Consolas,monospace; letter-spacing:.16em; }
h1 { margin:0; max-width:14ch; font-size:clamp(2rem,5.5vw,4.5rem); line-height:1.05; letter-spacing:-.04em; }
.lead { margin:1.5rem 0 0; max-width:36rem; color:var(--mut); font-size:clamp(.95rem,1.4vw,1.1rem); line-height:1.85; }
.readout { position:absolute; inset:1.5rem 1.5rem auto auto; margin:0; color:var(--mut); font:500 .68rem/1.6 ui-monospace,Consolas,monospace; letter-spacing:.1em; writing-mode:vertical-rl; z-index:2; }
.theme-toggle { position:absolute; inset:1.5rem auto auto 1.5rem; z-index:2; display:flex; gap:.25rem; }
.theme-toggle button {
  min-block-size:36px; padding-inline:.75rem; font:600 .72rem/1 ui-monospace,Consolas,monospace;
  letter-spacing:.08em; border:1px solid var(--mut); background:transparent; color:var(--ink); cursor:pointer; border-radius:4px; }
.theme-toggle button[aria-pressed="true"] { background:var(--signal); color:var(--bg-dark); border-color:var(--signal); }
@media (max-width:640px) { .copy{padding:2rem 1.25rem 3rem}.readout{display:none} }
.bare .copy, .bare .readout, .bare .theme-toggle { display:none; }
</style>

</head>
<body>
<script id="effect-config" type="application/json">{"firefly-alpha":0.4,"firefly-density":1.8e-05,"firefly-hue":75,"firefly-theme":0}</script>
<script>if (/[?&]bare=1/.test(location.search)) document.documentElement.classList.add('bare');
if (/[?&]tap=1/.test(location.search)) { addEventListener('load', function () {
  setTimeout(function () { [[0.5,0.5],[0.3,0.6],[0.7,0.4]].forEach(function (p, i) {
    setTimeout(function () { document.dispatchEvent(new MouseEvent('click',
      {clientX: innerWidth * p[0], clientY: innerHeight * p[1], bubbles: true})); }, i * 220);
  }); }, 300); }); }</script>
<script>(function(){var m=location.hash.match(/[#&]cfg=([^&]*)/);if(!m)return;
try{JSON.parse(decodeURIComponent(m[1]));document.getElementById('effect-config').textContent=decodeURIComponent(m[1]);}catch(e){}})();</script>
<script>(function(){
  var root=document.documentElement, cfgNode=document.getElementById('effect-config');
  var themeKeys=["firefly-theme"];
  function syncTheme(t){
    root.setAttribute('data-demo-theme',t);
    document.querySelectorAll('.theme-toggle button').forEach(function(b){
      b.setAttribute('aria-pressed',b.dataset.theme===t?'true':'false');});
    if(!themeKeys.length)return;
    try{var cfg=JSON.parse(cfgNode.textContent||'{}');
      themeKeys.forEach(function(k){cfg[k]=t==='dark'?0:1;});
      cfgNode.textContent=JSON.stringify(cfg);}catch(e){}
  }
  var fixed="dark";
  if(fixed){syncTheme(fixed);}else{var q=location.search.match(/[?&]theme=(light|dark)/); if(q) syncTheme(q[1]);}
  addEventListener('DOMContentLoaded',function(){
    document.querySelectorAll('.theme-toggle button').forEach(function(b){
      b.addEventListener('click',function(){syncTheme(b.dataset.theme);});});});})();</script>
<main class="sample">
  <div class="theme-toggle" aria-label="配色切替" hidden>
    <button type="button" data-theme="light" aria-pressed="false">LIGHT</button>
    <button type="button" data-theme="dark" aria-pressed="false">DARK</button>
  </div>
  <div class="effect" aria-hidden="true"><canvas id="effect-canvas"></canvas></div>
  <div class="copy">
    <p class="label">背景エフェクト</p>
    <h1>蛍</h1>
    <p class="lead">点が明滅しながら漂い、近づくほど強く光る。夏・幻想。暗い地に小さな光を漂わせたいとき。</p>
  </div>
  <p class="readout">単一HTML・動きを減らす設定に対応</p>
</main>
<script>
/* ---- ここから下は AK²Engine の実装をそのまま持ってきたもの ------------- */
/**
 * FireflyEffect / firefly.js
 * ==========================
 * 蛍の光 — 有機的な明滅を持つ蛍エフェクト
 *
 * 特徴:
 *  - Math.sin() によるサイン波で個別の明滅フェーズを管理
 *  - radialGradient + globalCompositeOperation:'lighter' で発光表現
 *  - オフスクリーン canvas で残像(トレイル)を維持
 *  - CSS 変数でページ側から外観を制御可能(JS 無編集)
 *
 * ── CSS 変数による設定 ─────────────────────────────
 *
 *  --firefly-hue      : 色相 0〜360(デフォルト 75 = 黄緑)
 *  --firefly-density  : 密度 粒子/px²(デフォルト 0.000018)
 *  --firefly-alpha    : 最大輝度 0〜1(デフォルト 0.40)
 *  --firefly-trail    : 残像フェード係数 0〜1(デフォルト 0.35)
 *  --firefly-theme    : 'light' で白背景モード(source-over・暗い黄金色)
 *
 *  例(紫の蛍、ごく控えめ):
 *    :root {
 *      --firefly-hue:     270;
 *      --firefly-density: 0.000012;
 *      --firefly-alpha:   0.3;
 *    }
 *
 * ── JS オプション ──────────────────────────────────
 *
 *  interactive: true|false  マウスへの反応(デフォルト true)
 *
 * 使い方:
 *  AK2.register(new FireflyEffect());
 *  AK2.register(new FireflyEffect({ interactive: false }));
 */

/* ----------------------------------------------------------------
   Firefly(個体)
---------------------------------------------------------------- */
class Firefly {
  #x; #y; #vx; #vy;
  #baseRadius; #maxRadius;
  #hue; #maxAlpha;
  #alpha; #blinkPhase; #blinkSpeed;
  #canvasWidth; #canvasHeight;

  /**
   * @param {number} x
   * @param {number} y
   * @param {number} w      - canvas 幅
   * @param {number} h      - canvas 高さ
   * @param {number} hue    - CSS 色相 0〜360
   * @param {number} maxAlpha - 最大輝度 0〜1
   */
  #lightMode;

  constructor(x, y, w, h, hue, maxAlpha, lightMode = false) {
    this.#canvasWidth  = w;
    this.#canvasHeight = h;
    this.#x = x;
    this.#y = y;
    this.#vx = (Math.random() - 0.5) * 0.5;
    this.#vy = (Math.random() - 0.5) * 0.5;
    this.#baseRadius   = Math.random() * 2 + 1;
    this.#maxRadius    = this.#baseRadius * (Math.random() * 4 + 3);
    this.#hue          = hue;
    this.#maxAlpha     = maxAlpha;
    this.#blinkPhase   = Math.random() * Math.PI * 2;
    this.#blinkSpeed   = Math.random() * 0.002 + 0.001;
    this.#alpha        = 0;
    this.#lightMode    = lightMode;
  }

  /**
   * @param {number} dt       - 経過ミリ秒
   * @param {number|null} mouseX
   * @param {number|null} mouseY
   */
  update(dt, mouseX, mouseY) {
    this.#x += this.#vx;
    this.#y += this.#vy;

    // 画面端ループ
    const r = this.#maxRadius;
    if (this.#x < -r)                    this.#x = this.#canvasWidth  + r;
    if (this.#x > this.#canvasWidth  + r) this.#x = -r;
    if (this.#y < -r)                    this.#y = this.#canvasHeight + r;
    if (this.#y > this.#canvasHeight + r) this.#y = -r;

    // マウスインタラクション(穏やかな反発 + 輝度アップ)
    let interactionFactor = 1;
    if (mouseX !== null && mouseY !== null) {
      const dx   = mouseX - this.#x;
      const dy   = mouseY - this.#y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      const maxDist = 180;
      if (dist < maxDist && dist > 0) {
        const force = (maxDist - dist) / maxDist;
        this.#vx -= (dx / dist) * force * 0.06;
        this.#vy -= (dy / dist) * force * 0.06;
        const speed = Math.sqrt(this.#vx * this.#vx + this.#vy * this.#vy);
        if (speed > 1.8) {
          this.#vx = (this.#vx / speed) * 1.8;
          this.#vy = (this.#vy / speed) * 1.8;
        }
        interactionFactor = 1 + force * 1.5;
      }
    }

    // 明滅(サイン波)
    this.#blinkPhase += this.#blinkSpeed * dt;
    const sineAlpha = (Math.sin(this.#blinkPhase) + 1) / 2; // 0〜1
    // maxAlpha を上限として明滅。最暗部は maxAlpha*0.1 程度
    this.#alpha = Math.min(1, (0.1 + sineAlpha * 0.9) * this.#maxAlpha * interactionFactor);
  }

  /** @param {CanvasRenderingContext2D} ctx */
  draw(ctx) {
    const r = this.#maxRadius;
    const g = ctx.createRadialGradient(this.#x, this.#y, 0, this.#x, this.#y, r);
    if (this.#lightMode) {
      // 白背景用: 暗い黄金/琥珀色
      g.addColorStop(0,   `hsla(${this.#hue}, 85%, 35%, ${this.#alpha})`);
      g.addColorStop(0.3, `hsla(${this.#hue}, 90%, 45%, ${this.#alpha * 0.5})`);
      g.addColorStop(1,   `hsla(${this.#hue}, 80%, 30%, 0)`);
    } else {
      g.addColorStop(0,   `hsla(${this.#hue}, 80%, 90%, ${this.#alpha})`);
      g.addColorStop(0.2, `hsla(${this.#hue}, 100%, 60%, ${this.#alpha * 0.6})`);
      g.addColorStop(1,   `hsla(${this.#hue}, 100%, 40%, 0)`);
    }
    ctx.beginPath();
    ctx.arc(this.#x, this.#y, r, 0, Math.PI * 2);
    ctx.fillStyle = g;
    ctx.fill();
  }
}


/* ----------------------------------------------------------------
   FireflyEffect(エンジン登録クラス)
---------------------------------------------------------------- */
class BgEffectFirefly {
  #canvas; #ctx;
  #width  = 0;
  #height = 0;
  #fireflies = [];
  #mouseX = null;
  #mouseY = null;
  #isInteractive;

  // CSS 変数から読み込む設定(init 時に確定)
  #hue        = 75;
  #density    = 0.000009; // 背景用デフォルト:1080p で約 18 個
  #maxAlpha   = 0.40;     // 控えめな輝度
  #trailAlpha = 0.35;     // 残像フェード係数(大きいほど尾が短い)
  #theme      = 'dark';   // 'dark' | 'light'

  // AK2Engine は毎フレーム clearRect するため、残像はオフスクリーン canvas で維持
  #trailCanvas = null;
  #trailCtx    = null;

  /** @param {{ interactive?: boolean }} options */
  constructor(options = {}) {
    this.#isInteractive = options.interactive !== undefined ? options.interactive : true;
  }

  // ── AK²Engine インターフェイス ─────────────────

  /** @param {HTMLCanvasElement} canvas @param {CanvasRenderingContext2D} ctx */
  init(canvas, ctx) {
    this.#canvas = canvas;
    this.#ctx    = ctx;
    this.#width  = canvas.width;
    this.#height = canvas.height;

    // CSS 変数から設定を読み込む(ページ側で :root { --firefly-* } で上書き可能)
    const style = getComputedStyle(document.documentElement);
    const cssHue     = parseFloat(style.getPropertyValue('--firefly-hue'));
    const cssDensity = parseFloat(style.getPropertyValue('--firefly-density'));
    const cssAlpha   = parseFloat(style.getPropertyValue('--firefly-alpha'));
    const cssTrail   = parseFloat(style.getPropertyValue('--firefly-trail'));
    const cssTheme   = style.getPropertyValue('--firefly-theme').trim();
    if (!isNaN(cssHue))       this.#hue        = cssHue;
    if (!isNaN(cssDensity))   this.#density    = cssDensity;
    if (!isNaN(cssAlpha))     this.#maxAlpha   = cssAlpha;
    if (!isNaN(cssTrail))     this.#trailAlpha = cssTrail;
    if (cssTheme === 'light') this.#theme      = 'light';

    this.#createTrailCanvas();
    this.#generateFireflies();
    this.#bindEvents();
  }

  /** @param {number} dt */
  update(dt) {
    this.#fireflies.forEach(f => f.update(dt, this.#mouseX, this.#mouseY));
  }

  /** @param {CanvasRenderingContext2D} ctx */
  draw(ctx) {
    if (this.#theme === 'light') {
      // 白背景用: destination-out でトレイルを透明フェード
      this.#trailCtx.globalCompositeOperation = 'destination-out';
      this.#trailCtx.fillStyle = `rgba(0, 0, 0, ${this.#trailAlpha})`;
      this.#trailCtx.fillRect(0, 0, this.#width, this.#height);
      this.#trailCtx.globalCompositeOperation = 'source-over';

      this.#fireflies.forEach(f => f.draw(this.#trailCtx));
    } else {
      // ダーク背景用: 暗い塗りつぶしでトレイル + lighter合成
      this.#trailCtx.globalCompositeOperation = 'source-over';
      this.#trailCtx.fillStyle = `rgba(5, 8, 10, ${this.#trailAlpha})`;
      this.#trailCtx.fillRect(0, 0, this.#width, this.#height);

      this.#trailCtx.globalCompositeOperation = 'lighter';
      this.#fireflies.forEach(f => f.draw(this.#trailCtx));
      this.#trailCtx.globalCompositeOperation = 'source-over';
    }

    // メイン canvas に転写
    ctx.drawImage(this.#trailCanvas, 0, 0);
  }

  /** @param {number} w @param {number} h */
  onResize(w, h) {
    this.#width  = w;
    this.#height = h;
    if (this.#trailCanvas) {
      this.#trailCanvas.width  = w;
      this.#trailCanvas.height = h;
    }
    this.#fireflies = [];
    this.#generateFireflies();
  }

  // ── ユーティリティ ────────────────────────────────

  #createTrailCanvas() {
    this.#trailCanvas = document.createElement('canvas');
    this.#trailCanvas.width  = this.#width;
    this.#trailCanvas.height = this.#height;
    this.#trailCtx = this.#trailCanvas.getContext('2d');
  }

  #generateFireflies() {
    const count     = Math.max(1, Math.floor(this.#width * this.#height * this.#density));
    const lightMode = this.#theme === 'light';
    for (let i = 0; i < count; i++) {
      this.#fireflies.push(new Firefly(
        Math.random() * this.#width,
        Math.random() * this.#height,
        this.#width, this.#height,
        this.#hue, this.#maxAlpha, lightMode
      ));
    }
  }

  #bindEvents() {
    window.addEventListener('pointermove', (e) => {
      if (!this.#isInteractive) return;
      this.#mouseX = e.clientX;
      this.#mouseY = e.clientY;
    });
    window.addEventListener('pointerout', () => {
      this.#mouseX = null;
      this.#mouseY = null;
    });
  }

  setInteractive(state) {
    this.#isInteractive = !!state;
    if (!this.#isInteractive) { this.#mouseX = null; this.#mouseY = null; }
  }

  get isInteractive() { return this.#isInteractive; }
}

// AK²Engine に登録
window.BgEffectFirefly = BgEffectFirefly;

/* ---- ここまで。以下は、この資料で回すための呼び出し ------------------- */
(function () {
  var canvas = document.getElementById('effect-canvas');
  var ctx = canvas.getContext('2d');
  var cfgNode = document.getElementById('effect-config');
  var reduced = matchMedia('(prefers-reduced-motion: reduce)');
  var eff = null, raf = 0, last = 0, W = 1, H = 1, active = true;
  var VARS = {"firefly-alpha": "--firefly-alpha", "firefly-density": "--firefly-density", "firefly-hue": "--firefly-hue", "firefly-theme": "--firefly-theme"};

  function applyVars(cfg) {
    Object.keys(VARS).forEach(function (k) {
      if (cfg[k] !== undefined && cfg[k] !== null) {
        document.documentElement.style.setProperty(VARS[k], String(cfg[k]));
      }
    });
  }
  function measure() {
    var box = canvas.getBoundingClientRect();
    W = Math.max(1, Math.round(box.width));
    H = Math.max(1, Math.round(box.height));
  }
  function build(cfg) {
    applyVars(cfg);
    measure();
    canvas.width = W; canvas.height = H;   /* init は canvas.width を読む */
    eff = new BgEffectFirefly({});
    if (eff.init) eff.init(canvas, ctx);
    fit();
  }
  function fit() {
    measure();
    var dpr = Math.min(2, window.devicePixelRatio || 1);
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    if (eff && eff.onResize) eff.onResize(W, H);
  }
  function paint(dt) {
    /* 描く直前に実寸を見る。読み込みの順によっては、最初の計測で幅0が返る
       (Browser ペインで 1x958 の buffer になった。ResizeObserver だけでは
       戻らないことがあるので、毎フレーム安く確かめる) */
    var box = canvas.getBoundingClientRect();
    if (Math.abs(Math.round(box.width) - W) > 1 || Math.abs(Math.round(box.height) - H) > 1) {
      fit();
    }
    ctx.clearRect(0, 0, W, H);
    if (!eff) return;
    if (eff.update) eff.update(dt);
    if (eff.draw) eff.draw(ctx);
  }
  function report() {
    document.documentElement.dataset.materialActive = String(active);
    document.documentElement.dataset.materialRunning = String(raf !== 0);
  }
  function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; report(); }
  function loop(time) {
    if (!active || reduced.matches || document.hidden) { stop(); paint(16.67); return; }
    var dt = last ? Math.min(60, time - last) : 16.67;
    last = time;
    paint(dt);
    raf = requestAnimationFrame(loop);
    report();
  }
  function start() {
    stop(); last = 0;
    if (active && !reduced.matches && !document.hidden) raf = requestAnimationFrame(loop);
    else paint(16.67);
    report();
  }
  addEventListener('message', function (e) {
    var d = e.data || {};
    if (d.type === 'material-config') {
      cfgNode.textContent = JSON.stringify(d.values || {});
      build(d.values || {});
      start();
    }
    if (d.type === 'material-active') { active = d.active === true; start(); }
  });
  document.addEventListener('visibilitychange', start);
  reduced.addEventListener('change', start);
  new ResizeObserver(fit).observe(canvas);
  addEventListener('pagehide', stop, {once: true});
  build(JSON.parse(cfgNode.textContent));
  start();
})();
</script>
</body>
</html>

AIエージェントへ渡す場合は、「このHTMLを対象ページのヒーロー背景へ組み込み、文字と主要ボタンを前面に残してください」と添えます。

組み込み時の確認#

関連#

この資料は、Webサイトを作るときに引くためのものです。 何のために作ったか。作っているのは AK²Lab。