Web Animations Easy
Blob Morph
Morph a soft blob between compatible paths with SMIL and expose a JavaScript pause control.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0a0b10;
--panel: #12141d;
--line: #262a38;
--text: #e8eaf2;
--muted: #949bb3;
--accent: #7c5cff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 20px;
background:
radial-gradient(60rem 40rem at 50% -10%, #1a1330 0%, transparent 70%),
var(--bg);
color: var(--text);
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
button, input { font: inherit; }
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap; border: 0;
}
.demo {
width: min(560px, 100%);
display: grid;
gap: 22px;
}
.demo__head h1 { margin: 0 0 6px; font-size: 22px; letter-spacing: -0.02em; }
.demo__head p { margin: 0; color: var(--muted); font-size: 13.5px; }
/* ---- stage ---- */
.stage {
position: relative;
display: grid;
place-items: center;
padding: 18px;
border: 1px solid var(--line);
border-radius: 22px;
background:
radial-gradient(28rem 20rem at 50% 30%, rgba(124, 92, 255, 0.16), transparent 70%),
var(--panel);
overflow: hidden;
}
.stage::after {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
background-size: 32px 32px;
pointer-events: none;
}
.stage__svg {
width: min(320px, 78vw);
height: auto;
overflow: visible;
filter: drop-shadow(0 18px 40px rgba(124, 92, 255, 0.35));
transition: filter 0.3s ease;
}
.stage__ghost {
color: rgba(232, 234, 242, 0.45);
stroke-width: 1.25;
stroke-dasharray: 5 5;
opacity: 0;
transition: opacity 0.2s ease;
}
.stage.is-outline .stage__ghost { opacity: 1; }
.stage.is-paused .stage__svg {
filter: drop-shadow(0 12px 26px rgba(0, 0, 0, 0.5)) saturate(0.6);
}
.stage__readout {
position: absolute;
left: 14px;
bottom: 12px;
z-index: 1;
font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
background: rgba(10, 11, 16, 0.72);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
}
/* ---- controls ---- */
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.btn {
appearance: none;
cursor: pointer;
font-weight: 600;
font-size: 13.5px;
color: var(--text);
background: #191c27;
border: 1px solid var(--line);
border-radius: 11px;
padding: 9px 16px;
transition: background 0.15s ease, transform 0.1s ease;
}
.btn:hover { background: #212536; }
.btn:active { transform: translateY(1px); }
.btn:focus-visible, input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn--primary {
background: linear-gradient(180deg, #8b6dff, #6a49f0);
border-color: #6a49f0;
}
.btn--primary:hover { background: linear-gradient(180deg, #9b80ff, #7a5bf5); }
.btn--primary[aria-pressed="true"] {
background: #191c27;
border-color: var(--line);
}
.field {
display: grid;
gap: 5px;
font-size: 12px;
color: var(--muted);
flex: 1 1 168px;
min-width: 168px;
}
.field em { font-style: normal; color: var(--text); font-variant-numeric: tabular-nums; }
.field input[type="range"] { width: 100%; accent-color: var(--accent); }
.field--switch {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
min-width: 0;
cursor: pointer;
}
.field--switch input { accent-color: var(--accent); width: 16px; height: 16px; }
@media (prefers-reduced-motion: reduce) {
.stage__ghost, .stage__svg, .btn { transition: none; }
}/**
* Blob Morph — real path interpolation, zero dependencies.
*
* Each shape is a ring of POINTS radial samples around a centre. Because every
* shape uses the same point count in the same angular order, the shapes are
* "compatible": morphing is a plain lerp of matching coordinates, then a
* Catmull-Rom -> cubic Bezier conversion to keep the outline soft and closed.
*/
const POINTS = 8;
const CX = 160;
const CY = 160;
const BASE_R = 108;
const HOLD = 260; // ms paused on each shape before the next transition
const svgPath = document.getElementById("blob");
const ghostPath = document.getElementById("blobGhost");
const stage = document.getElementById("stage");
const toggleBtn = document.getElementById("toggle");
const stepBtn = document.getElementById("step");
const speedInput = document.getElementById("speed");
const speedValue = document.getElementById("speedValue");
const outlineInput = document.getElementById("outline");
const readout = document.getElementById("readout");
/* ---------- shape definitions (radius multiplier per angular slot) ---------- */
const SHAPES = [
[1.0, 0.82, 1.06, 0.88, 1.02, 0.84, 1.08, 0.9],
[0.86, 1.1, 0.8, 1.04, 0.92, 1.12, 0.82, 1.0],
[1.12, 0.94, 0.9, 1.1, 0.86, 0.98, 1.06, 0.8],
[0.94, 1.04, 1.0, 0.82, 1.1, 0.9, 0.96, 1.08],
];
function toPoints(shape) {
return shape.map((k, i) => {
const angle = (i / POINTS) * Math.PI * 2 - Math.PI / 2;
const r = BASE_R * k;
return [CX + Math.cos(angle) * r, CY + Math.sin(angle) * r];
});
}
const RINGS = SHAPES.map(toPoints);
/* ---------- geometry helpers ---------- */
function lerpRings(a, b, t) {
const out = new Array(a.length);
for (let i = 0; i < a.length; i++) {
out[i] = [a[i][0] + (b[i][0] - a[i][0]) * t, a[i][1] + (b[i][1] - a[i][1]) * t];
}
return out;
}
/** Closed Catmull-Rom spline through all points, emitted as cubic Beziers. */
function toPathData(points) {
const n = points.length;
const at = (i) => points[(i + n) % n];
let d = `M ${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}`;
for (let i = 0; i < n; i++) {
const p0 = at(i - 1);
const p1 = at(i);
const p2 = at(i + 1);
const p3 = at(i + 2);
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 easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
/* ---------- animation state ---------- */
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const DURATION = 1400; // ms per morph at 1x
let from = 0;
let to = 1;
let progress = 0; // 0..1 within the current morph
let holdLeft = HOLD;
let speed = 1;
let playing = true;
let raf = null;
let last = 0;
function render() {
const t = easeInOut(progress);
svgPath.setAttribute("d", toPathData(lerpRings(RINGS[from], RINGS[to], t)));
ghostPath.setAttribute("d", toPathData(RINGS[to]));
readout.textContent = `shape ${from + 1} → ${to + 1} · ${Math.round(t * 100)}%`;
}
function advance() {
from = to;
to = (to + 1) % RINGS.length;
progress = 0;
holdLeft = HOLD;
}
function frame(now) {
const dt = Math.min(now - last, 64);
last = now;
if (holdLeft > 0) {
holdLeft -= dt * speed;
} else {
progress += (dt * speed) / DURATION;
if (progress >= 1) {
progress = 1;
render();
advance();
raf = requestAnimationFrame(frame);
return;
}
}
render();
raf = requestAnimationFrame(frame);
}
function play() {
if (raf !== null) return;
playing = true;
last = performance.now();
raf = requestAnimationFrame(frame);
stage.classList.remove("is-paused");
toggleBtn.textContent = "Pause";
toggleBtn.setAttribute("aria-pressed", "false");
}
function pause() {
if (raf !== null) cancelAnimationFrame(raf);
raf = null;
playing = false;
stage.classList.add("is-paused");
toggleBtn.textContent = "Play";
toggleBtn.setAttribute("aria-pressed", "true");
}
/* ---------- controls ---------- */
toggleBtn.addEventListener("click", () => (playing ? pause() : play()));
// Works while paused too: snaps to the next shape (keyboard-friendly stepping).
stepBtn.addEventListener("click", () => {
progress = 1;
render();
advance();
render();
});
speedInput.addEventListener("input", () => {
speed = Number(speedInput.value);
speedValue.textContent = `${speed.toFixed(2)}×`;
});
outlineInput.addEventListener("change", () => {
stage.classList.toggle("is-outline", outlineInput.checked);
});
// Pause when the tab is hidden so timing never jumps on return.
document.addEventListener("visibilitychange", () => {
if (document.hidden && playing) {
cancelAnimationFrame(raf);
raf = null;
} else if (!document.hidden && playing && raf === null) {
last = performance.now();
raf = requestAnimationFrame(frame);
}
});
/* ---------- boot ---------- */
render();
if (reduced.matches) {
// Reduced motion: hold a static blob; the buttons still allow manual stepping.
pause();
readout.textContent = "static · reduced motion";
} else {
play();
}<link rel="stylesheet" href="style.css" />
<main class="demo" data-demo="Blob Morph">
<header class="demo__head">
<h1>Blob Morph</h1>
<p>A soft blob morphs between compatible paths. Every shape shares the same point count, so interpolation stays smooth.</p>
</header>
<section class="stage" id="stage" aria-labelledby="stage-title">
<h2 id="stage-title" class="sr-only">Morphing blob</h2>
<svg class="stage__svg" viewBox="0 0 320 320" role="img" aria-labelledby="blob-desc">
<title id="blob-desc">An organic blob shape continuously morphing between four silhouettes</title>
<defs>
<linearGradient id="blobFill" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7c5cff" />
<stop offset="55%" stop-color="#22d3ee" />
<stop offset="100%" stop-color="#f472b6" />
</linearGradient>
</defs>
<path id="blob" class="stage__blob" fill="url(#blobFill)" d="" />
<path id="blobGhost" class="stage__ghost" fill="none" stroke="currentColor" d="" />
</svg>
<output class="stage__readout" id="readout" aria-live="polite">shape 1 → 2</output>
</section>
<div class="controls">
<button type="button" id="toggle" class="btn btn--primary" aria-pressed="false">Pause</button>
<button type="button" id="step" class="btn">Next shape</button>
<label class="field" for="speed">
<span>Speed <em id="speedValue">1.00×</em></span>
<input type="range" id="speed" min="0.25" max="3" step="0.05" value="1" />
</label>
<label class="field field--switch" for="outline">
<input type="checkbox" id="outline" />
<span>Show target outline</span>
</label>
</div>
</main>
<script src="script.js"></script>import { useState } from "react";
export function SvgBlobMorph() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Blob 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>
);
}Blob Morph
Morph a soft blob between compatible paths with SMIL and expose a JavaScript pause 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.