SVGモーフィング(形状変形)

pathのd属性の頂点を線形補間して形を滑らかに変える技術。ライブラリ無しでブロブやハートへ連続変形します。

#svg#js#animation#morph

ライブデモ

使用例(お題: カフェ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。