SVGモーフィング(形状変形)
pathのd属性の頂点を線形補間して形を滑らかに変える技術。ライブラリ無しでブロブやハートへ連続変形します。
ライブデモ
使用例(お題: カフェ MOON BREW)
この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- MOON BREW:豆→葉→雫へ変形するブランドマーク -->
<section class="mb-brand">
<div class="mb-brand__mark">
<svg viewBox="0 0 200 200" role="img" aria-label="コーヒーをモチーフに変形するブランドマーク">
<defs>
<linearGradient id="mbAmber" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e0a85f" />
<stop offset="100%" stop-color="#9c5e20" />
</linearGradient>
</defs>
<!-- 主役:JSでd属性を補間して滑らかに変形 -->
<path id="mbMorph" fill="url(#mbAmber)" />
</svg>
</div>
<div class="mb-brand__text">
<span class="mb-brand__eyebrow">SINCE 2014 ・ SPECIALTY ROASTERY</span>
<h1 class="mb-brand__name">☾ MOON BREW</h1>
<p class="mb-brand__lead">
一粒の<b id="mbShape">豆</b>から、香りの物語を。<br>
自家焙煎のスペシャルティコーヒー専門店。
</p>
<a class="mb-brand__btn" href="#">焙煎所のこだわりを見る</a>
</div>
</section>
CSS
/* MOON BREW:変形するブランドマーク */
:root {
--cream: #f5ede1;
--brown: #2b1d12;
--amber: #c98a3b;
}
* { box-sizing: border-box; }
body {
margin: 0;
height: 400px;
display: grid;
place-items: center;
background:
radial-gradient(120% 120% at 18% 10%, #fbf5ec 0%, var(--cream) 60%, #ead9c2 100%);
font-family: "Hiragino Mincho ProN", "Yu Mincho", serif;
color: var(--brown);
overflow: hidden;
}
.mb-brand {
display: grid;
grid-template-columns: 190px 1fr;
align-items: center;
gap: 8px;
width: min(620px, 94vw);
padding: 10px;
}
/* 主役マークの土台 */
.mb-brand__mark {
display: grid;
place-items: center;
aspect-ratio: 1;
border-radius: 28px;
background: linear-gradient(160deg, #fff 0%, #f3e6d4 100%);
box-shadow: 0 18px 40px -18px rgba(43, 29, 18, 0.45);
}
.mb-brand__mark svg { width: 78%; height: auto; display: block; filter: drop-shadow(0 6px 8px rgba(156, 94, 32, 0.3)); }
.mb-brand__eyebrow {
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
font-size: 10px;
letter-spacing: 0.22em;
color: var(--amber);
font-weight: 700;
}
.mb-brand__name {
margin: 8px 0 10px;
font-size: 26px;
letter-spacing: 0.06em;
font-weight: 700;
}
.mb-brand__lead {
margin: 0 0 18px;
font-size: 13px;
line-height: 1.85;
color: #5a4632;
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
}
.mb-brand__lead b {
color: var(--amber);
font-weight: 700;
padding: 0 2px;
}
.mb-brand__btn {
display: inline-block;
padding: 10px 20px;
border-radius: 999px;
background: var(--brown);
color: var(--cream);
font-size: 12px;
font-weight: 700;
text-decoration: none;
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
transition: transform 0.2s, background 0.2s;
}
.mb-brand__btn:hover { transform: translateY(-2px); background: var(--amber); }
JavaScript
// d属性の頂点を線形補間し、豆→葉→雫へ滑らかにモーフィング(ライブラリ不要)
const path = document.getElementById("mbMorph");
const label = document.getElementById("mbShape");
if (path) {
// 全形状を「同じ点数・同じコマンド構成」で定義(M 1点 + C×4=12点 = 26数値)。
// index 1:1 で補間でき、必ず有効なパスになる。
const shapes = {
豆: [
100, 24,
150, 24, 178, 60, 178, 100,
178, 150, 150, 176, 100, 176,
50, 176, 22, 150, 22, 100,
22, 60, 50, 24, 100, 24,
],
葉: [
100, 18,
150, 40, 176, 90, 158, 150,
140, 178, 120, 184, 100, 184,
80, 184, 60, 178, 42, 150,
24, 90, 50, 40, 100, 18,
],
雫: [
100, 16,
118, 54, 150, 80, 168, 114,
186, 150, 150, 186, 100, 186,
50, 186, 14, 150, 32, 114,
50, 80, 82, 54, 100, 16,
],
};
const order = ["豆", "葉", "雫"];
// 数値配列を3次ベジェのパス文字列へ(先頭M、残りを6数値=1セグメントずつC)
const toPath = (a) => {
let d = `M ${a[0]} ${a[1]} C`;
for (let i = 2; i < a.length; i += 2) d += ` ${a[i]} ${a[i + 1]}`;
return d + " Z";
};
const lerp = (a, b, t) => a + (b - a) * t;
const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
const DURATION = 1100; // 変形時間
const HOLD = 900; // 形を保持
let idx = 0;
let start = null;
let from = shapes[order[0]];
let to = shapes[order[1]];
const tick = (now) => {
if (start === null) start = now;
const elapsed = now - start;
const e = easeInOut(Math.min(elapsed / DURATION, 1));
const cur = from.map((v, i) => lerp(v, to[i], e)); // 点数が揃うのでNaN無し
path.setAttribute("d", toPath(cur));
if (elapsed >= DURATION + HOLD) {
idx = (idx + 1) % order.length;
from = shapes[order[idx]];
to = shapes[order[(idx + 1) % order.length]];
if (label) label.textContent = order[(idx + 1) % order.length];
start = null;
}
requestAnimationFrame(tick);
};
// 初期描画
path.setAttribute("d", toPath(from));
if (label) label.textContent = order[1];
// reduced-motion なら静止表示
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
path.setAttribute("d", toPath(shapes.豆));
if (label) label.textContent = "豆";
} else {
requestAnimationFrame(tick);
}
}
コード
HTML
<!-- SVGモーフィング: pathのd属性を補間して形状を滑らかに変形 -->
<div class="wrap">
<svg class="blob" viewBox="0 0 200 200" role="img" aria-label="形が変化するブロブ">
<defs>
<linearGradient id="blobGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f472b6" />
<stop offset="50%" stop-color="#a78bfa" />
<stop offset="100%" stop-color="#38bdf8" />
</linearGradient>
</defs>
<path id="morph" fill="url(#blobGrad)" d="" />
</svg>
<p class="label" aria-live="polite">morph → <span id="shapeName">heart</span></p>
</div>
CSS
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", system-ui, sans-serif;
/* 暗い背景に淡い光のグロー */
background: #0b0c1a;
background-image:
radial-gradient(40% 50% at 30% 30%, rgba(167, 139, 250, .25), transparent 70%),
radial-gradient(45% 55% at 75% 70%, rgba(56, 189, 248, .22), transparent 70%);
overflow: hidden;
}
.wrap {
display: grid;
justify-items: center;
gap: 14px;
}
.blob {
width: min(60vw, 230px);
height: auto;
/* ふわっとした影でブロブを浮かせる */
filter: drop-shadow(0 18px 40px rgba(167, 139, 250, .45));
animation: float 6s ease-in-out infinite;
}
#morph {
/* d はJSで補間。微妙な回転で生命感 */
transform-origin: 100px 100px;
animation: spin 18s linear infinite;
}
.label {
margin: 0;
font-size: 13px;
letter-spacing: .12em;
text-transform: uppercase;
color: #cbd5e1;
}
.label span {
color: #f9a8d4;
font-weight: 700;
}
@keyframes float {
50% { transform: translateY(-12px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.blob, #morph { animation: none; }
}
JavaScript
// d属性を持つ複数形状の頂点を線形補間してモーフィングする(ライブラリ不要)
const path = document.getElementById("morph");
const nameEl = document.getElementById("shapeName");
if (path) {
// 全形状を「同じ点数・同じコマンド構成」で定義する。
// 構成: M(1点) + 4本の3次ベジェ C(各3点=12点) = 計13点 / 26数値。
// これにより from→to を index 1:1 で補間でき、必ず有効なパスになる。
const shapes = {
blob: [
100, 14, // M (始点: 上)
150, 14, 186, 50, 186, 100, // C → 右
186, 150, 150, 186, 100, 186, // C → 下
50, 186, 14, 150, 14, 100, // C → 左
14, 50, 50, 14, 100, 14, // C → 上(始点へ)
],
heart: [
100, 60,
100, 30, 70, 18, 50, 18, // 左の膨らみ
18, 18, 18, 56, 40, 80, // 左下へ
62, 104, 100, 140, 100, 140, // 谷の底(尖り)
100, 140, 182, 70, 150, 18, // 右側を回り込み
],
star: [
100, 18,
118, 64, 140, 72, 176, 76, // 右上の谷→右の山
138, 104, 150, 150, 130, 168, // 右下の谷→下の山
100, 142, 70, 168, 50, 150, // 左下の谷→左の山
62, 104, 24, 76, 60, 72, // 左上の谷→上(始点へ)
],
drop: [
100, 14,
120, 52, 150, 78, 168, 112, // 右肩
186, 150, 150, 188, 100, 188, // 右下のふくらみ
50, 188, 14, 150, 32, 112, // 左下のふくらみ
50, 78, 80, 52, 100, 14, // 左肩(尖った先端へ)
],
};
const order = ["blob", "heart", "star", "drop"];
// 数値配列を 3次ベジェのパス文字列へ(先頭M、残りを6数値=1セグメントずつC)
const toPath = (a) => {
let d = `M ${a[0]} ${a[1]} C`;
for (let i = 2; i < a.length; i += 2) d += ` ${a[i]} ${a[i + 1]}`;
return d + " Z";
};
const lerp = (a, b, t) => a + (b - a) * t;
const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
let idx = 0;
const DURATION = 1100; // 変形時間
const HOLD = 700; // 形を保持する時間
let start = null;
let from = shapes[order[0]];
let to = shapes[order[1]];
const tick = (now) => {
if (start === null) start = now;
const elapsed = now - start;
const t = Math.min(elapsed / DURATION, 1);
const e = easeInOut(t);
// 各頂点を補間して描画(点数が揃っているのでNaNは出ない)
const cur = from.map((v, i) => lerp(v, to[i], e));
path.setAttribute("d", toPath(cur));
if (elapsed >= DURATION + HOLD) {
idx = (idx + 1) % order.length;
from = shapes[order[idx]];
to = shapes[order[(idx + 1) % order.length]];
if (nameEl) nameEl.textContent = order[(idx + 1) % order.length];
start = null;
}
requestAnimationFrame(tick);
};
// 初期描画(アニメ開始前から形が見えるように)
path.setAttribute("d", toPath(from));
if (nameEl) nameEl.textContent = order[1];
// reduced-motion なら静止表示
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
path.setAttribute("d", toPath(shapes.heart));
if (nameEl) nameEl.textContent = "heart";
} else {
requestAnimationFrame(tick);
}
}
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「SVGモーフィング(形状変形)」の効果を追加してください。
# 追加してほしい効果
SVGモーフィング(形状変形)(SVG エフェクト)
pathのd属性の頂点を線形補間して形を滑らかに変える技術。ライブラリ無しでブロブやハートへ連続変形します。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- SVGモーフィング: pathのd属性を補間して形状を滑らかに変形 -->
<div class="wrap">
<svg class="blob" viewBox="0 0 200 200" role="img" aria-label="形が変化するブロブ">
<defs>
<linearGradient id="blobGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f472b6" />
<stop offset="50%" stop-color="#a78bfa" />
<stop offset="100%" stop-color="#38bdf8" />
</linearGradient>
</defs>
<path id="morph" fill="url(#blobGrad)" d="" />
</svg>
<p class="label" aria-live="polite">morph → <span id="shapeName">heart</span></p>
</div>
【CSS】
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", system-ui, sans-serif;
/* 暗い背景に淡い光のグロー */
background: #0b0c1a;
background-image:
radial-gradient(40% 50% at 30% 30%, rgba(167, 139, 250, .25), transparent 70%),
radial-gradient(45% 55% at 75% 70%, rgba(56, 189, 248, .22), transparent 70%);
overflow: hidden;
}
.wrap {
display: grid;
justify-items: center;
gap: 14px;
}
.blob {
width: min(60vw, 230px);
height: auto;
/* ふわっとした影でブロブを浮かせる */
filter: drop-shadow(0 18px 40px rgba(167, 139, 250, .45));
animation: float 6s ease-in-out infinite;
}
#morph {
/* d はJSで補間。微妙な回転で生命感 */
transform-origin: 100px 100px;
animation: spin 18s linear infinite;
}
.label {
margin: 0;
font-size: 13px;
letter-spacing: .12em;
text-transform: uppercase;
color: #cbd5e1;
}
.label span {
color: #f9a8d4;
font-weight: 700;
}
@keyframes float {
50% { transform: translateY(-12px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.blob, #morph { animation: none; }
}
【JavaScript】
// d属性を持つ複数形状の頂点を線形補間してモーフィングする(ライブラリ不要)
const path = document.getElementById("morph");
const nameEl = document.getElementById("shapeName");
if (path) {
// 全形状を「同じ点数・同じコマンド構成」で定義する。
// 構成: M(1点) + 4本の3次ベジェ C(各3点=12点) = 計13点 / 26数値。
// これにより from→to を index 1:1 で補間でき、必ず有効なパスになる。
const shapes = {
blob: [
100, 14, // M (始点: 上)
150, 14, 186, 50, 186, 100, // C → 右
186, 150, 150, 186, 100, 186, // C → 下
50, 186, 14, 150, 14, 100, // C → 左
14, 50, 50, 14, 100, 14, // C → 上(始点へ)
],
heart: [
100, 60,
100, 30, 70, 18, 50, 18, // 左の膨らみ
18, 18, 18, 56, 40, 80, // 左下へ
62, 104, 100, 140, 100, 140, // 谷の底(尖り)
100, 140, 182, 70, 150, 18, // 右側を回り込み
],
star: [
100, 18,
118, 64, 140, 72, 176, 76, // 右上の谷→右の山
138, 104, 150, 150, 130, 168, // 右下の谷→下の山
100, 142, 70, 168, 50, 150, // 左下の谷→左の山
62, 104, 24, 76, 60, 72, // 左上の谷→上(始点へ)
],
drop: [
100, 14,
120, 52, 150, 78, 168, 112, // 右肩
186, 150, 150, 188, 100, 188, // 右下のふくらみ
50, 188, 14, 150, 32, 112, // 左下のふくらみ
50, 78, 80, 52, 100, 14, // 左肩(尖った先端へ)
],
};
const order = ["blob", "heart", "star", "drop"];
// 数値配列を 3次ベジェのパス文字列へ(先頭M、残りを6数値=1セグメントずつC)
const toPath = (a) => {
let d = `M ${a[0]} ${a[1]} C`;
for (let i = 2; i < a.length; i += 2) d += ` ${a[i]} ${a[i + 1]}`;
return d + " Z";
};
const lerp = (a, b, t) => a + (b - a) * t;
const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
let idx = 0;
const DURATION = 1100; // 変形時間
const HOLD = 700; // 形を保持する時間
let start = null;
let from = shapes[order[0]];
let to = shapes[order[1]];
const tick = (now) => {
if (start === null) start = now;
const elapsed = now - start;
const t = Math.min(elapsed / DURATION, 1);
const e = easeInOut(t);
// 各頂点を補間して描画(点数が揃っているのでNaNは出ない)
const cur = from.map((v, i) => lerp(v, to[i], e));
path.setAttribute("d", toPath(cur));
if (elapsed >= DURATION + HOLD) {
idx = (idx + 1) % order.length;
from = shapes[order[idx]];
to = shapes[order[(idx + 1) % order.length]];
if (nameEl) nameEl.textContent = order[(idx + 1) % order.length];
start = null;
}
requestAnimationFrame(tick);
};
// 初期描画(アニメ開始前から形が見えるように)
path.setAttribute("d", toPath(from));
if (nameEl) nameEl.textContent = order[1];
// reduced-motion なら静止表示
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
path.setAttribute("d", toPath(shapes.heart));
if (nameEl) nameEl.textContent = "heart";
} else {
requestAnimationFrame(tick);
}
}
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。