モーフィングローダー

SVGパスの点群をイージング補間して滑らかに形を変えるローディング表現。待機画面のアクセントに最適です。

#svg#javascript#animation#loader

ライブデモ

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

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

HTML
<!-- Sakura:楽曲MV読込中のモーフィングローダー -->
<section class="ml-stage">
  <div class="ml-petal ml-petal--1">🌸</div>
  <div class="ml-petal ml-petal--2">🌸</div>

  <div class="ml-card">
    <p class="ml-brand">🌸 Sakura</p>

    <!-- モーフするSVGローダー(パスは点群をJSで補間) -->
    <div class="ml-loader">
      <svg viewBox="0 0 120 120" width="96" height="96" aria-label="読み込み中">
        <defs>
          <linearGradient id="mlGrad" x1="0" y1="0" x2="1" y2="1">
            <stop offset="0" stop-color="#ff9ec0"/>
            <stop offset="1" stop-color="#ff6fa5"/>
          </linearGradient>
        </defs>
        <path id="mlPath" fill="url(#mlGrad)" d=""></path>
      </svg>
    </div>

    <p class="ml-title">新曲MV「春一番デイズ」</p>
    <p class="ml-sub" id="mlSub">読み込み中… 0%</p>
    <div class="ml-bar"><span class="ml-bar__fill" id="mlFill"></span></div>
  </div>
</section>
CSS
/* Sakura:MV読込のモーフィングローダー */
:root {
  --pink: #ffd1e0;
  --hot: #ff6fa5;
  --gray: #f2f3f5;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  display: grid;
  place-items: center;
  font-family: "Hiragino Kaku Gothic ProN", "Yu Gothic UI", system-ui, sans-serif;
  background: radial-gradient(120% 120% at 50% 0%, #fff5f9 0%, var(--pink) 75%, #ffc1d6 100%);
  color: #5a3b48;
  overflow: hidden;
}

.ml-stage { position: relative; width: 100%; height: 400px; display: grid; place-items: center; }

/* ふわふわ漂う花びら */
.ml-petal { position: absolute; font-size: 22px; opacity: 0.8; animation: ml-drift 6s ease-in-out infinite; }
.ml-petal--1 { top: 16%; left: 16%; animation-delay: 0s; }
.ml-petal--2 { bottom: 14%; right: 18%; animation-delay: 2s; }
@keyframes ml-drift {
  0%, 100% { transform: translateY(0) rotate(0); }
  50% { transform: translateY(-12px) rotate(20deg); }
}

.ml-card {
  width: min(300px, 86vw);
  padding: 24px 26px 22px;
  border-radius: 22px;
  background: rgba(255, 255, 255, 0.78);
  border: 1px solid rgba(255, 255, 255, 0.9);
  box-shadow: 0 24px 56px -22px rgba(214, 86, 132, 0.5);
  text-align: center;
  backdrop-filter: blur(6px);
}
.ml-brand { margin: 0 0 12px; font-size: 14px; font-weight: 800; letter-spacing: 0.08em; color: var(--hot); }

.ml-loader { display: grid; place-items: center; margin: 4px 0 14px; }
.ml-loader svg { filter: drop-shadow(0 6px 14px rgba(255, 111, 165, 0.45)); animation: ml-spin 6s linear infinite; }
@keyframes ml-spin { to { transform: rotate(360deg); } }

.ml-title { margin: 0 0 4px; font-size: 15px; font-weight: 700; }
.ml-sub { margin: 0 0 12px; font-size: 12px; color: #9a7080; }

.ml-bar { height: 6px; border-radius: 999px; background: rgba(255, 111, 165, 0.18); overflow: hidden; }
.ml-bar__fill {
  display: block; height: 100%; width: 0%;
  border-radius: 999px;
  background: linear-gradient(90deg, #ff9ec0, var(--hot));
  transition: width 0.3s ease;
}

@media (prefers-reduced-motion: reduce) {
  .ml-petal, .ml-loader svg { animation: none; }
}
JavaScript
// Sakura:点群を補間してSVGパスを滑らかにモーフさせるローダー
(() => {
  const path = document.getElementById("mlPath");
  const fill = document.getElementById("mlFill");
  const sub = document.getElementById("mlSub");
  if (!path) return; // null安全

  const CX = 60, CY = 60; // 中心
  const N = 24;           // サンプル点数

  // 半径を角度で変える関数を複数用意(花・星・円・しずく)
  const shapes = [
    (a) => 38 + 10 * Math.sin(5 * a),               // 桜の花びら
    (a) => 30 + 16 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.6), // 星形
    () => 40,                                        // 円
    (a) => 36 + 12 * Math.cos(3 * a),                // 丸み三角
  ];

  // 角度ごとの点群を生成
  const sample = (fn) => {
    const pts = [];
    for (let i = 0; i < N; i++) {
      const a = (i / N) * Math.PI * 2;
      const r = fn(a);
      pts.push([CX + r * Math.cos(a), CY + r * Math.sin(a)]);
    }
    return pts;
  };

  // 2つの点群を t で線形補間
  const lerpPts = (p1, p2, t) =>
    p1.map(([x1, y1], i) => {
      const [x2, y2] = p2[i];
      return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
    });

  // 点群を滑らかな閉パス(Catmull-Rom → ベジェ近似)へ
  const toPath = (pts) => {
    const len = pts.length;
    let d = `M ${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)} `;
    for (let i = 0; i < len; i++) {
      const p0 = pts[(i - 1 + len) % len];
      const p1 = pts[i];
      const p2 = pts[(i + 1) % len];
      const p3 = pts[(i + 2) % len];
      const c1x = p1[0] + (p2[0] - p0[0]) / 6;
      const c1y = p1[1] + (p2[1] - p0[1]) / 6;
      const c2x = p2[0] - (p3[0] - p1[0]) / 6;
      const c2y = p2[1] - (p3[1] - p1[1]) / 6;
      d += `C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2[0].toFixed(2)} ${p2[1].toFixed(2)} `;
    }
    return d + "Z";
  };

  // 各形の点群を事前計算
  const clouds = shapes.map(sample);

  // 緩急のあるイージング
  const easeInOut = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;

  const DUR = 1100; // 1モーフの所要(ms)
  let idx = 0;
  let start = null;

  const frame = (ts) => {
    if (start === null) start = ts;
    const raw = Math.min((ts - start) / DUR, 1);
    const t = easeInOut(raw);
    const from = clouds[idx];
    const to = clouds[(idx + 1) % clouds.length];
    path.setAttribute("d", toPath(lerpPts(from, to, t)));

    if (raw >= 1) {
      idx = (idx + 1) % clouds.length;
      start = ts;
    }
    requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);

  // 読み込みプログレスの演出(85%付近で一旦溜める)
  let p = 0;
  const tick = () => {
    const target = p < 85 ? 4 : 0.6;
    p = Math.min(p + Math.random() * target, 98);
    if (fill) fill.style.width = `${p}%`;
    if (sub) sub.textContent = `読み込み中… ${Math.floor(p)}%`;
  };
  setInterval(tick, 300);
})();

コード

HTML
<!-- モーフィングローダー:SVGパスが滑らかに形を変えるローディング表現 -->
<div class="morph-stage">
  <div class="morph-wrap">
    <svg class="morph-svg" viewBox="0 0 120 120" aria-label="loading">
      <defs>
        <linearGradient id="morphGrad" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stop-color="#ff7eb3"/>
          <stop offset="50%" stop-color="#a47cff"/>
          <stop offset="100%" stop-color="#39d3ff"/>
        </linearGradient>
      </defs>
      <!-- このパスを JS で複数形状へ補間する -->
      <path id="morphPath" fill="url(#morphGrad)" d=""></path>
    </svg>
    <p class="morph-label">Loading<span class="morph-dots" id="morphDots"></span></p>
  </div>
</div>
CSS
/* 暗めの背景にカラフルなブロブを浮かべる */
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 360px;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Sans", "Yu Gothic UI", system-ui, sans-serif;
  background:
    radial-gradient(70% 70% at 50% 30%, #1c1f3a 0%, #0c0d1c 70%),
    #0c0d1c;
  color: #e8eaff;
}
.morph-stage { display: grid; place-items: center; padding: 24px; }
.morph-wrap { display: grid; place-items: center; gap: 14px; }

.morph-svg {
  width: 150px; height: 150px;
  /* 全体を緩やかに回しつつ柔らかい影を落とす */
  filter: drop-shadow(0 12px 26px rgba(164, 124, 255, 0.45));
  animation: morphSpin 9s linear infinite;
}
@keyframes morphSpin { to { transform: rotate(360deg); } }

.morph-label {
  margin: 0;
  font-size: 14px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: #aeb4e6;
}
.morph-dots { display: inline-block; width: 1.4em; text-align: left; }

@media (prefers-reduced-motion: reduce) {
  .morph-svg { animation: none; }
}
JavaScript
// モーフィングローダー:複数のブロブ形状を点群で補間して滑らかに変形
(() => {
  const path = document.getElementById('morphPath');
  const dots = document.getElementById('morphDots');
  if (!path) return; // null安全

  const CX = 60, CY = 60, N = 12; // 中心と制御点数
  const TAU = Math.PI * 2;

  // 半径配列から閉じた滑らかパス(カトマル風)を生成
  const toPath = (radii) => {
    const pts = radii.map((r, i) => {
      const a = (i / N) * TAU - Math.PI / 2;
      return [CX + Math.cos(a) * r, CY + Math.sin(a) * r];
    });
    let d = `M ${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)} `;
    for (let i = 0; i < N; i++) {
      const p0 = pts[(i - 1 + N) % N], p1 = pts[i];
      const p2 = pts[(i + 1) % N], p3 = pts[(i + 2) % N];
      // カトマル・ロムをベジェ制御点へ変換
      const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
      const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
      d += `C ${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2[0].toFixed(2)} ${p2[1].toFixed(2)} `;
    }
    return d + 'Z';
  };

  // ランダムなブロブの半径セットを作る
  const makeShape = () => Array.from({ length: N }, () => 30 + Math.random() * 18);

  let from = makeShape();
  let to = makeShape();
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // イージング(ease-in-out)
  const ease = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;

  let start = null;
  const DUR = 1400; // 1形状あたりの時間
  const tick = (now) => {
    if (start === null) start = now;
    let t = (now - start) / DUR;
    if (t >= 1) { // 次の形状へ
      t = 0; start = now; from = to; to = makeShape();
    }
    const e = ease(t);
    const cur = from.map((r, i) => r + (to[i] - r) * e);
    path.setAttribute('d', toPath(cur));
    requestAnimationFrame(tick);
  };

  if (reduce) {
    path.setAttribute('d', toPath(from)); // 静止形状のみ
  } else {
    requestAnimationFrame(tick);
  }

  // ローディングのドット表現
  if (dots) {
    let c = 0;
    setInterval(() => { c = (c + 1) % 4; dots.textContent = '.'.repeat(c); }, 400);
  }
})();

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

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

# 追加してほしい効果
モーフィングローダー(アニメーション & トランジション)
SVGパスの点群をイージング補間して滑らかに形を変えるローディング表現。待機画面のアクセントに最適です。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- モーフィングローダー:SVGパスが滑らかに形を変えるローディング表現 -->
<div class="morph-stage">
  <div class="morph-wrap">
    <svg class="morph-svg" viewBox="0 0 120 120" aria-label="loading">
      <defs>
        <linearGradient id="morphGrad" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stop-color="#ff7eb3"/>
          <stop offset="50%" stop-color="#a47cff"/>
          <stop offset="100%" stop-color="#39d3ff"/>
        </linearGradient>
      </defs>
      <!-- このパスを JS で複数形状へ補間する -->
      <path id="morphPath" fill="url(#morphGrad)" d=""></path>
    </svg>
    <p class="morph-label">Loading<span class="morph-dots" id="morphDots"></span></p>
  </div>
</div>

【CSS】
/* 暗めの背景にカラフルなブロブを浮かべる */
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 360px;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Sans", "Yu Gothic UI", system-ui, sans-serif;
  background:
    radial-gradient(70% 70% at 50% 30%, #1c1f3a 0%, #0c0d1c 70%),
    #0c0d1c;
  color: #e8eaff;
}
.morph-stage { display: grid; place-items: center; padding: 24px; }
.morph-wrap { display: grid; place-items: center; gap: 14px; }

.morph-svg {
  width: 150px; height: 150px;
  /* 全体を緩やかに回しつつ柔らかい影を落とす */
  filter: drop-shadow(0 12px 26px rgba(164, 124, 255, 0.45));
  animation: morphSpin 9s linear infinite;
}
@keyframes morphSpin { to { transform: rotate(360deg); } }

.morph-label {
  margin: 0;
  font-size: 14px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: #aeb4e6;
}
.morph-dots { display: inline-block; width: 1.4em; text-align: left; }

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

【JavaScript】
// モーフィングローダー:複数のブロブ形状を点群で補間して滑らかに変形
(() => {
  const path = document.getElementById('morphPath');
  const dots = document.getElementById('morphDots');
  if (!path) return; // null安全

  const CX = 60, CY = 60, N = 12; // 中心と制御点数
  const TAU = Math.PI * 2;

  // 半径配列から閉じた滑らかパス(カトマル風)を生成
  const toPath = (radii) => {
    const pts = radii.map((r, i) => {
      const a = (i / N) * TAU - Math.PI / 2;
      return [CX + Math.cos(a) * r, CY + Math.sin(a) * r];
    });
    let d = `M ${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)} `;
    for (let i = 0; i < N; i++) {
      const p0 = pts[(i - 1 + N) % N], p1 = pts[i];
      const p2 = pts[(i + 1) % N], p3 = pts[(i + 2) % N];
      // カトマル・ロムをベジェ制御点へ変換
      const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
      const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
      d += `C ${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2[0].toFixed(2)} ${p2[1].toFixed(2)} `;
    }
    return d + 'Z';
  };

  // ランダムなブロブの半径セットを作る
  const makeShape = () => Array.from({ length: N }, () => 30 + Math.random() * 18);

  let from = makeShape();
  let to = makeShape();
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // イージング(ease-in-out)
  const ease = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;

  let start = null;
  const DUR = 1400; // 1形状あたりの時間
  const tick = (now) => {
    if (start === null) start = now;
    let t = (now - start) / DUR;
    if (t >= 1) { // 次の形状へ
      t = 0; start = now; from = to; to = makeShape();
    }
    const e = ease(t);
    const cur = from.map((r, i) => r + (to[i] - r) * e);
    path.setAttribute('d', toPath(cur));
    requestAnimationFrame(tick);
  };

  if (reduce) {
    path.setAttribute('d', toPath(from)); // 静止形状のみ
  } else {
    requestAnimationFrame(tick);
  }

  // ローディングのドット表現
  if (dots) {
    let c = 0;
    setInterval(() => { c = (c + 1) % 4; dots.textContent = '.'.repeat(c); }, 400);
  }
})();

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

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