以前「SafariでのSVGモーフィング」という記事で、d 属性の入れ替えでモーフィングアニメーションがSafariで動かない話を書いた。その後、SVGパスツールを作る中で改めてこの問題と向き合ったのでまとめる。
なぜ setAttribute("d", ...) では動かないのか
CSS側で transition: d 1s ease を指定していても、setAttribute("d", newPath) で属性を変更してもトランジションが発火しない。
原因は、CSSのトランジションは CSS プロパティ の変化を監視しているが、setAttribute は HTML属性 を変えているだけだから。SVGの d 属性とCSSの d プロパティは別物として扱われるブラウザがある。
// ❌ CSS transitionが発火しない(ブラウザによる)
el.style.transition = 'd 1s ease';
el.setAttribute("d", newPath);
// ✅ CSS プロパティを直接変更する
el.style.transition = 'd 1s ease';
el.style.d = `path("${newPath}")`;
style.d = 'path("...")' がCSSの d プロパティを直接変更するので、トランジションが正しく発火する。
再生時のリセット処理
「再生」ボタンを押したとき、前のアニメーションをキャンセルして最初の状態に戻してから再生する必要がある。
function doAnime() {
const el = document.querySelector("#after");
const duration = 1;
const easing = "ease";
// ① transition を無効にして Before 状態にリセット
el.style.transition = 'none';
el.style.d = `path("${d_before}")`;
el.setAttribute("d", d_before);
// ② reflow を強制(これがないと transition が無効のまま After に飛ぶ)
void el.getBoundingClientRect();
// ③ transition を有効にして After へアニメーション
el.style.transition = `d ${duration}s ${easing}`;
el.style.d = `path("${d_after}")`;
el.setAttribute("d", d_after);
}
getBoundingClientRect() でリフローを強制しないと、① と ③ が一瞬にまとめて処理されてしまい、アニメーションが見えない。
パスの構造を揃える
モーフィングで一番重要なのがここ。Before と After のパスは、サブパスの数・各サブパス内のコマンド数・コマンドの種類が完全に一致している必要がある。
太陽→月のアニメーションを例にすると:
太陽(Before): 9サブパス
M ... A A A A z ← 4つのAコマンド
M ... A A A A A z ← 5つのAコマンド
...(8つの点)
M ... C C C C Z ← 4つのCコマンド(円)
月(After): 9サブパス
M ... a a a a Z ← 4つのaコマンド(半径ほぼ0)
M ... a a a a a z ← 5つのaコマンド
...(8つの点、中央に集約)
M ... C C C C z ← 4つのCコマンド(三日月)
ここで 絶対座標(大文字)と相対座標(小文字) の混在が問題になる。
絶対・相対の混在問題
太陽の円サブパスは絶対座標 C、月の三日月サブパスは相対座標 c で書かれていた場合、JS で数値を補間するとおかしくなる。
補間は「テンプレート(Before)のコマンド文字を使いながら数値だけ After に近づける」方式なので、Before の C(絶対)に After の c(相対)の値を代入してしまう。
対策は三日月を絶対座標に変換しておくこと:
// 相対座標(元)
M 103.3,64 c 0,21.7 -17.6,39.3 -39.3,39.3 ...
// 絶対座標(変換後)
M 103.3,64 C 103.3,85.7 85.7,103.3 64,103.3 ...
半径0のarcは表示されない
8つの点を中央に収縮させるとき、a 0,0 0 0 0 0,0 のように半径0のarcを使うと、Safariでは「存在しないパス」として扱われてモーフィングできない。a 0.001,0.001 ... のように極小の値にする。
「一寸の虫にも五分の魂」、arcコマンドにも小さくても半径を与えてあげること
fill-rule の影響
複数のサブパスが重なるとき、fill-rule によって見た目が変わる。
nonzero(デフォルト): 重なりはそのまま塗られるevenodd: 偶数回重なった領域が穴になる
アニメーション途中で点が月の内側を通過するとき、evenodd だと穴が開いて見える。デザインに応じて選択する。
実際のコード(最小構成)
<svg viewBox="0 0 128 128">
<path id="target" d="/* Before パス */"
style="fill: #333; transition: d 1s ease;" />
</svg>
<script>
const el = document.getElementById("target");
const d_before = "/* Before パス */";
const d_after = "/* After パス */";
function play() {
el.style.transition = 'none';
el.style.d = `path("${d_before}")`;
el.setAttribute("d", d_before);
void el.getBoundingClientRect();
el.style.transition = 'd 1s ease';
el.style.d = `path("${d_after}")`;
el.setAttribute("d", d_after);
}
</script>
まとめ
| ポイント | 対策 |
|---|---|
setAttribute では transition が発火しない | style.d = 'path("...")' を使う |
| 再生リセット | transition: none → reflow → transition 有効 |
| パス構造の不一致 | サブパス数・コマンド数・コマンド種類を揃える |
| 絶対/相対の混在 | どちらか一方に統一する |
| 半径0のarc | 0.001 など極小値にする |
| Safari | style.d + CSS transition で動作確認済み |
作ったSVGパスツールはこちら → tech.sinayaka.com/tools/svg