Web Animations Medium
SVG Path Morph
Animate between compatible SVG paths with SMIL and a JavaScript toggle for reduced-motion-friendly control.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
*, *::before, *::after { box-sizing: border-box; }
:root {
--bg: #0b1020;
--panel: #121a2d;
--panel-2: #18233b;
--line: #263555;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
--accent-2: #56e0c0;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(120% 90% at 50% 0%, #16203a 0%, var(--bg) 62%);
color: var(--text);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: clamp(1rem, 4vw, 3rem);
display: grid;
place-items: center;
}
button, input { font: inherit; }
.demo {
width: min(560px, 100%);
display: grid;
gap: 20px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 24px 70px #0006;
}
.demo__head h1 { margin: 0 0 6px; font-size: 1.35rem; letter-spacing: -0.01em; }
.muted { margin: 0; color: var(--muted); font-size: 0.92rem; }
code { background: #0d1526; border: 1px solid var(--line); border-radius: 5px; padding: 0 4px; font-size: 0.85em; }
.stage {
position: relative;
display: grid;
place-items: center;
padding: 18px 18px 34px;
border: 1px solid var(--line);
border-radius: 18px;
background: linear-gradient(180deg, #10192d, #0c1424);
}
.stage__svg { width: min(280px, 70vw); height: auto; overflow: visible; }
.stage__path {
fill: rgba(120, 169, 255, 0.16);
stroke: var(--accent);
stroke-width: 2.5;
stroke-linejoin: round;
filter: drop-shadow(0 0 18px rgba(120, 169, 255, 0.35));
}
.stage__label {
position: absolute;
bottom: 12px;
font-size: 0.75rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.controls { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
flex: 1 1 auto;
padding: 9px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--muted);
font-size: 0.88rem;
cursor: pointer;
transition: color .18s, border-color .18s, background .18s;
}
.chip:hover { color: var(--text); border-color: #3a4f7d; }
.chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.chip.is-active {
color: #0b1020;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent;
font-weight: 600;
}
.scrub {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel-2);
}
.scrub label { font-size: 0.85rem; color: var(--muted); }
.scrub input[type="range"] { width: 100%; accent-color: var(--accent); }
.scrub__val {
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
color: var(--accent-2);
min-width: 4ch;
text-align: right;
}
@media (prefers-reduced-motion: reduce) {
.stage__path { filter: none; }
.chip { transition: none; }
}(() => {
const path = document.getElementById("morphPath");
const label = document.getElementById("shapeLabel");
const range = document.getElementById("progress");
const rangeVal = document.getElementById("progressVal");
const chips = [...document.querySelectorAll(".chip")];
if (!path) return;
const CX = 100, CY = 100, R = 70, N = 16; // shared segment count => compatible paths
// radius(i) for each shape, sampled at the same N angles.
const shapes = {
circle: () => R,
blob: (i) => R * (0.8 + 0.22 * Math.sin(i * 1.5) + 0.1 * Math.cos(i * 3)),
star: (i) => (i % 2 === 0 ? R : R * 0.42),
square: (i) => {
const a = (i / N) * Math.PI * 2 - Math.PI / 2;
const c = Math.abs(Math.cos(a)) || 1e-6;
const s = Math.abs(Math.sin(a)) || 1e-6;
return Math.min(R / c, R / s, R * 1.35);
},
};
// Closed Catmull-Rom -> cubic Bezier, returned as a flat number array.
function build(fn) {
const pts = [];
for (let i = 0; i < N; i++) {
const a = (i / N) * Math.PI * 2 - Math.PI / 2;
const r = fn(i);
pts.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
const nums = [pts[0][0], pts[0][1]];
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];
nums.push(
p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6,
p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6,
p2[0], p2[1]
);
}
return nums;
}
const toD = (n) => {
let d = `M ${n[0].toFixed(2)} ${n[1].toFixed(2)}`;
for (let i = 2; i < n.length; i += 6) {
d += " C " + n.slice(i, i + 6).map((v) => v.toFixed(2)).join(" ");
}
return d + " Z";
};
const cache = {};
for (const key of Object.keys(shapes)) cache[key] = build(shapes[key]);
const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
let from = cache.blob, to = cache.blob, t = 1, raf = null;
function render() {
path.setAttribute("d", toD(lerp(from, to, ease(t))));
const pct = Math.round(t * 100);
range.value = String(pct);
rangeVal.textContent = pct + "%";
}
render();
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
function morphTo(name, btn) {
chips.forEach((c) => {
const on = c === btn;
c.classList.toggle("is-active", on);
c.setAttribute("aria-pressed", String(on));
});
label.textContent = btn.textContent.trim();
from = lerp(from, to, ease(t)); // start from what is currently on screen
to = cache[name];
t = 0;
if (raf) cancelAnimationFrame(raf);
if (reduced.matches) { t = 1; render(); return; }
const start = performance.now();
const dur = 700;
const step = (now) => {
t = Math.min(1, (now - start) / dur);
render();
raf = t < 1 ? requestAnimationFrame(step) : null;
};
raf = requestAnimationFrame(step);
}
chips.forEach((btn) => btn.addEventListener("click", () => morphTo(btn.dataset.shape, btn)));
range.addEventListener("input", () => {
if (raf) cancelAnimationFrame(raf);
raf = null;
t = Number(range.value) / 100;
render();
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG Path Morph</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="SVG Path Morph">
<header class="demo__head">
<h1>SVG Path Morph</h1>
<p class="muted">
Every shape is generated with the same number of cubic segments, so the morph is a plain
numeric interpolation between two <code>d</code> attributes. Reduced motion snaps instead of tweening.
</p>
</header>
<div class="stage">
<svg class="stage__svg" viewBox="0 0 200 200" role="img" aria-labelledby="morphTitle">
<title id="morphTitle">Animated morphing shape</title>
<path id="morphPath" class="stage__path" d="" />
</svg>
<output class="stage__label" id="shapeLabel" aria-live="polite">Blob</output>
</div>
<div class="controls" role="group" aria-label="Target shape">
<button type="button" class="chip is-active" data-shape="blob" aria-pressed="true">Blob</button>
<button type="button" class="chip" data-shape="circle" aria-pressed="false">Circle</button>
<button type="button" class="chip" data-shape="star" aria-pressed="false">Star</button>
<button type="button" class="chip" data-shape="square" aria-pressed="false">Square</button>
</div>
<div class="scrub">
<label for="progress">Scrub morph</label>
<input id="progress" type="range" min="0" max="100" value="100" step="1" />
<span class="scrub__val" id="progressVal">100%</span>
</div>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function SvgPathMorph() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>SVG Path Morph</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}SVG Path Morph
Animate between compatible SVG paths with SMIL and a JavaScript toggle for reduced-motion-friendly control.
Support notes
SVG animation can be driven by SMIL, CSS, or JavaScript. Keep path point counts compatible for reliable morphing and provide a non-animated state for reduced motion.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.