モーフィングローダー
SVGパスの点群をイージング補間して滑らかに形を変えるローディング表現。待機画面のアクセントに最適です。
ライブデモ
使用例(お題: アイドルグループ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。