このページの中身

桜吹雪

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

花びらが回転しながら舞い落ち、風で横へ流れる。

試して調整する#

見た目を調整する

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

判断#

向く場面: 春の告知、和風の店舗、入学や新生活の案内。

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

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

組み込み条件#

依存: 素の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">{"cherry-blossom-alpha":1.0,"cherry-blossom-count":60,"cherry-blossom-hue":345}</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=[];
  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=null;
  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="配色切替">
    <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 の実装をそのまま持ってきたもの ------------- */
/**
 * AK²-Engine / effects/cherry-blossom.js
 * ==========================================
 * 桜の花びら — 物理演算版(CherryBlossomEffect)
 *
 * 空気力学的な姿勢制御エンジン搭載。
 * 縦・横の2モードを切り替えながら落下。X/Y独立のきりもみ回転で
 * 花びらが「パタパタ」翻る表現。桜・春・ブライダル系に。
 *
 * ── CSS 変数による設定 ─────────────────────────────
 *
 *  --cherry-blossom-count
 *    花びらの数
 *    デフォルト: 60
 *    推奨範囲:   20(控えめ)〜 120(満開)
 *    カードプレビュー用: 10〜15
 *
 *  --cherry-blossom-alpha
 *    アルファ係数 0〜1
 *    デフォルト: 1.0(= 0.85 の標準値)
 *    推奨範囲:   0.5〜1.0
 *
 *  --cherry-blossom-hue
 *    花びらの基準色相(0〜360)
 *    デフォルト: 345(桜ピンク)
 *    例: 330(濃いピンク)/ 0(赤)/ 350(薄いピンク寄り)
 *
 * 使い方:
 *  AK2.register(new CherryBlossomEffect());
 */
class BgEffectCherryBlossom {

  /** @type {object} コンストラクタオプション(CSS変数より優先) */
  #options = {};

  /** @param {{{ count?: number, alpha?: number, hue?: number }}} options */
  constructor(options = {}) {
    this.#options = options;
  }


  /** @type {number} */
  #w = 0;

  /** @type {number} */
  #h = 0;

  /** @type {Array<object>} */
  #particles = [];

  /**
   * 花びらの数 — CSS 変数 --cherry-blossom-count で上書き可
   * デフォルト: 60 | 推奨: 20〜120
   * @type {number}
   */
  #count = 60;

  /**
   * アルファ係数 — CSS 変数 --cherry-blossom-alpha で上書き可
   * デフォルト: 1.0 | 推奨: 0.5〜1.0
   * @type {number}
   */
  #alphaScale = 1.0;

  /**
   * 基準色相 — CSS 変数 --cherry-blossom-hue で上書き可
   * デフォルト: 345(桜ピンク)
   * @type {number}
   */
  #baseHue = 345;

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

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

    const style = getComputedStyle(document.documentElement);
    const n = parseInt(style.getPropertyValue('--cherry-blossom-count'));
    const a = parseFloat(style.getPropertyValue('--cherry-blossom-alpha'));
    const h = parseFloat(style.getPropertyValue('--cherry-blossom-hue'));
    if (n > 0)     this.#count      = n;
    if (!isNaN(a)) this.#alphaScale = a;
    if (!isNaN(h)) this.#baseHue    = h;

    // コンストラクタオプションは CSS 変数より優先(セクション個別指定に使用)
    if (this.#options.count !== undefined) this.#count = this.#options.count;
    if (this.#options.alpha !== undefined) this.#alphaScale = this.#options.alpha;
    if (this.#options.hue !== undefined) this.#baseHue = this.#options.hue;

    this.#particles = Array.from({ length: this.#count }, () => this.#reset({}, true));
  }

  onResize(w, h) {
    this.#w = w;
    this.#h = h;
  }

  /**
   * @param {number} dt - 前フレームからの経過ミリ秒
   */
  update(dt) {
    const timeScale = dt / 6;

    for (const p of this.#particles) {
      // 1. Y方向の落下 + 不規則な速度変動
      p.driftTimerY -= timeScale;
      if (p.driftTimerY <= 0) {
        p.driftTargetY = (Math.random() - 0.5) * 0.5;
        p.driftTimerY  = Math.random() * 150 + 50;
      }
      p.vy += (p.driftTargetY - p.vy) * 0.01 * timeScale;
      p.y  += (p.speedY + p.vy) * timeScale * 0.8;

      // 2. X方向の漂い
      p.driftTimer -= timeScale;
      if (p.driftTimer <= 0) {
        p.driftTarget = (Math.random() - 0.5) * 1.5;
        p.driftTimer  = Math.random() * 150 + 50;
      }
      p.vx += (p.driftTarget - p.vx) * 0.01 * timeScale;
      const totalVx = p.speedX + p.vx;
      p.x  += totalVx * timeScale;

      // 3. 空気力学的な姿勢制御(逆立ち回避)
      p.postureTimer -= timeScale;
      if (p.postureTimer <= 0) {
        p.isVertical   = !p.isVertical;
        p.postureTimer = p.isVertical
          ? (Math.random() * 80  + 40)
          : (Math.random() * 300 + 150);
      }

      let ca = p.angle;
      while (ca >  Math.PI) ca -= Math.PI * 2;
      while (ca < -Math.PI) ca += Math.PI * 2;
      p.angle = ca;

      let torque;
      if (p.isVertical) {
        torque = Math.sin((0 - p.angle) / 2) * 0.004;
      } else {
        const target = totalVx > 0 ? -Math.PI / 2 : Math.PI / 2;
        torque = Math.sin((target - p.angle) / 2) * 0.005;
      }
      p.spin += torque * timeScale;
      p.spin *= Math.pow(0.94, timeScale);
      p.angle += p.spin * timeScale;

      // 4. X/Y独立のきりもみ回転(パタパタ感)
      p.flipPhaseX += p.flipSpeedX * timeScale;
      p.flipPhaseY += p.flipSpeedY * timeScale;

      p.wobble += p.wobbleSpeed * timeScale;

      // 画面外リセット
      if (p.y > this.#h + p.size * 2 ||
          p.x < -p.size * 2 ||
          p.x >  this.#w + p.size * 2) {
        this.#reset(p, false);
      }
    }
  }

  /**
   * @param {CanvasRenderingContext2D} ctx
   */
  draw(ctx) {
    for (const p of this.#particles) {
      // チカチカ防止:最低 30% の幅を保証(X/Y独立)
      const minScale = 0.3;

      const rawX  = Math.cos(p.flipPhaseX);
      const signX = rawX >= 0 ? 1 : -1;
      const flipScaleX = signX * (Math.abs(rawX) * (1 - minScale) + minScale);

      const rawY  = Math.cos(p.flipPhaseY);
      const signY = rawY >= 0 ? 1 : -1;
      const flipScaleY = signY * (Math.abs(rawY) * (1 - minScale) + minScale);

      const drawAngle = p.angle + Math.sin(p.wobble) * 0.15;
      this.#drawPetal(ctx, p.x, p.y, p.size, drawAngle, p.hue, flipScaleX, flipScaleY, p.alpha);
    }
  }

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

  /**
   * @param {object}  p
   * @param {boolean} isFirstTime
   */
  #reset(p, isFirstTime) {
    p.x = Math.random() * this.#w;
    p.y = isFirstTime ? Math.random() * this.#h : -50;

    p.size   = Math.random() * 6 + 5;
    p.speedY = Math.random() * 0.5 + 0.6;
    p.speedX = (Math.random() - 0.5) * 0.2;
    p.vx     = 0;
    p.vy     = 0;

    p.driftTarget  = 0;
    p.driftTimer   = Math.random() * 100;
    p.driftTargetY = 0;
    p.driftTimerY  = Math.random() * 100;

    p.wobble      = Math.random() * Math.PI * 2;
    p.wobbleSpeed = Math.random() * 0.02 + 0.01;

    const ivx = p.speedX + p.vx;
    p.angle        = (ivx > 0 ? -1 : 1) * (Math.PI / 2) + (Math.random() - 0.5) * 0.5;
    p.spin         = 0;
    p.isVertical   = Math.random() > 0.8;
    p.postureTimer = Math.random() * 200 + 100;

    p.flipPhaseX = Math.random() * Math.PI * 2;
    p.flipSpeedX = Math.random() * 0.06 + 0.04; // 横幅の変動は多め
    p.flipPhaseY = Math.random() * Math.PI * 2;
    p.flipSpeedY = Math.random() * 0.02 + 0.01; // 縦幅の変動は少なめ

    p.hue   = this.#baseHue + (Math.random() * 20 - 10);
    p.alpha = 0.85 * this.#alphaScale;
    return p;
  }

  /**
   * 桜の花びら形状を描画
   */
  #drawPetal(ctx, x, y, size, angle, hue, flipScaleX, flipScaleY, alpha) {
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(angle);
    ctx.scale(flipScaleX, flipScaleY);

    const w = size * 1.5;
    ctx.fillStyle = `hsla(${hue}, 100%, 80%, ${alpha})`;
    ctx.beginPath();
    ctx.moveTo(0, -size);
    ctx.bezierCurveTo( w * 0.6, -size,  w, size, 0, size);
    ctx.bezierCurveTo(-w, size, -w * 0.6, -size, 0, -size);
    ctx.fill();
    ctx.restore();
  }
}

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

/* ---- ここまで。以下は、この資料で回すための呼び出し ------------------- */
(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 = {"cherry-blossom-alpha": "--cherry-blossom-alpha", "cherry-blossom-count": "--cherry-blossom-count", "cherry-blossom-hue": "--cherry-blossom-hue"};

  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 BgEffectCherryBlossom({});
    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。