Patterns Easy
Morphing Blobs
Three approaches to organic shape animation: CSS border-radius, SVG path morphing, and Canvas bezier curves.
Open in Lab
MCP
css-border-radius svg-path canvas-bezier
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #070a12;
--text: #f0f4fb;
--muted: #8a95a8;
--accent: #86e8ff;
--border: #263249;
}
body {
background: var(--bg);
color: var(--text);
font-family: "Inter", "SF Pro Display", system-ui, sans-serif;
min-height: 100vh;
}
.header {
text-align: center;
padding: 5rem 2rem 2rem;
}
.eyebrow {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
margin-bottom: 1rem;
}
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.subtitle {
font-size: clamp(0.9rem, 2vw, 1.05rem);
color: var(--muted);
max-width: 520px;
margin: 0.75rem auto 0;
line-height: 1.6;
}
.demos {
max-width: 900px;
margin: 0 auto;
padding: 2rem 2rem 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.5rem;
}
.demo-block {
background: rgba(18, 26, 43, 0.4);
border: 1px solid var(--border);
border-radius: 18px;
padding: 2rem;
text-align: center;
}
.demo-block h2 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.4rem;
}
.demo-desc {
font-size: 0.8rem;
color: var(--muted);
line-height: 1.5;
margin-bottom: 1.5rem;
}
.blob-stage {
display: flex;
align-items: center;
justify-content: center;
height: 220px;
position: relative;
}
/* CSS Blobs */
.css-blob {
position: absolute;
width: 140px;
height: 140px;
animation: morph 8s ease-in-out infinite;
filter: blur(0px);
}
.blob-1 {
background: linear-gradient(135deg, #86e8ff, #3d9eff);
opacity: 0.7;
animation-delay: 0s;
}
.blob-2 {
background: linear-gradient(135deg, #ae52ff, #7c2aff);
opacity: 0.5;
animation-delay: -2.5s;
width: 120px;
height: 120px;
}
.blob-3 {
background: linear-gradient(135deg, #ff40d6, #ff6b9d);
opacity: 0.4;
animation-delay: -5s;
width: 100px;
height: 100px;
}
@keyframes morph {
0%,
100% {
border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%;
transform: rotate(0deg) scale(1);
}
25% {
border-radius: 30% 60% 70% 40% / 50% 60% 30% 60%;
transform: rotate(90deg) scale(1.05);
}
50% {
border-radius: 50% 60% 30% 60% / 30% 50% 70% 60%;
transform: rotate(180deg) scale(0.95);
}
75% {
border-radius: 60% 30% 60% 40% / 70% 40% 50% 60%;
transform: rotate(270deg) scale(1.02);
}
}
.reduced-motion .css-blob {
animation: none;
border-radius: 50%;
}
/* SVG Blob */
.svg-blob {
width: 180px;
height: 180px;
}
/* Canvas Blob */
#canvas-blob {
width: 220px;
height: 220px;
}
.footer {
text-align: center;
padding: 2.5rem 2rem 4rem;
}
.btn-back {
display: inline-block;
padding: 0.7rem 2rem;
border-radius: 999px;
border: 1px solid rgba(134, 232, 255, 0.3);
color: var(--accent);
text-decoration: none;
font: 600 0.85rem / 1 "Inter", system-ui, sans-serif;
transition: all 0.25s;
}
.btn-back:hover {
background: rgba(134, 232, 255, 0.08);
border-color: var(--accent);
}
@media (max-width: 640px) {
.demos {
grid-template-columns: 1fr;
padding: 1rem 1rem 0;
}
}if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
initDemoShell({
title: "Morphing Blobs",
category: "css-canvas",
tech: ["css-border-radius", "svg-path", "canvas-bezier"],
});
let reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add("reduced-motion");
// โโโ SVG Path Morphing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const svgPath = document.getElementById("svg-morph-path");
// Two blob shapes with matching point counts (cubic bezier, 6 segments)
const shapeA = [
[100, 20],
[160, 30],
[190, 80],
[180, 140],
[150, 180],
[90, 190],
[30, 160],
[10, 100],
[20, 50],
[60, 20],
];
const shapeB = [
[100, 10],
[170, 40],
[195, 100],
[170, 160],
[130, 195],
[70, 185],
[20, 150],
[5, 90],
[30, 40],
[70, 10],
];
const shapeC = [
[110, 15],
[180, 50],
[185, 110],
[160, 170],
[110, 190],
[50, 175],
[15, 130],
[10, 70],
[40, 25],
[80, 10],
];
const svgShapes = [shapeA, shapeB, shapeC, shapeB];
function pointsToPath(points) {
if (points.length < 3) return "";
let d = `M ${points[0][0]} ${points[0][1]}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[(i + 1) % points.length];
const cpx1 = prev[0] + (curr[0] - prev[0]) * 0.5;
const cpy1 = prev[1] + (curr[1] - prev[1]) * 0.5;
const cpx2 = curr[0] - (next[0] - prev[0]) * 0.15;
const cpy2 = curr[1] - (next[1] - prev[1]) * 0.15;
d += ` C ${cpx1} ${cpy1}, ${cpx2} ${cpy2}, ${curr[0]} ${curr[1]}`;
}
d += " Z";
return d;
}
function lerpPoints(a, b, t) {
return a.map((pt, i) => [pt[0] + (b[i][0] - pt[0]) * t, pt[1] + (b[i][1] - pt[1]) * t]);
}
let svgTime = 0;
const SVG_SEGMENT_DURATION = 2000; // ms per transition
let svgRAF;
function animateSVG(timestamp) {
svgTime += 16.67; // ~60fps timestep
const totalDuration = SVG_SEGMENT_DURATION * svgShapes.length;
const loopTime = svgTime % totalDuration;
const segIndex = Math.floor(loopTime / SVG_SEGMENT_DURATION);
const segT = (loopTime % SVG_SEGMENT_DURATION) / SVG_SEGMENT_DURATION;
// Smooth easing
const eased = segT < 0.5 ? 4 * segT * segT * segT : 1 - Math.pow(-2 * segT + 2, 3) / 2;
const from = svgShapes[segIndex];
const to = svgShapes[(segIndex + 1) % svgShapes.length];
const interpolated = lerpPoints(from, to, eased);
svgPath.setAttribute("d", pointsToPath(interpolated));
svgRAF = requestAnimationFrame(animateSVG);
}
// Set initial shape
svgPath.setAttribute("d", pointsToPath(shapeA));
if (!reduced) {
svgRAF = requestAnimationFrame(animateSVG);
}
// โโโ Canvas Bezier Blob โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const canvas = document.getElementById("canvas-blob");
const ctx = canvas.getContext("2d");
const W = canvas.width;
const H = canvas.height;
const CX = W / 2;
const CY = H / 2;
const NUM_POINTS = 8;
const BASE_RADIUS = 80;
const WOBBLE = 25;
// Each point has a unique frequency and phase
const blobPoints = Array.from({ length: NUM_POINTS }, (_, i) => ({
angle: (i / NUM_POINTS) * Math.PI * 2,
freq: 0.5 + Math.random() * 1.5,
phase: Math.random() * Math.PI * 2,
amp: WOBBLE * (0.6 + Math.random() * 0.4),
}));
let canvasTime = 0;
let canvasRAF;
function drawBlob() {
canvasTime += 0.015;
ctx.clearRect(0, 0, W, H);
// Calculate points on the blob
const pts = blobPoints.map((p) => {
const r = BASE_RADIUS + Math.sin(canvasTime * p.freq + p.phase) * p.amp;
return {
x: CX + Math.cos(p.angle) * r,
y: CY + Math.sin(p.angle) * r,
};
});
// Draw using smooth bezier curves (Catmull-Rom to Bezier conversion)
const gradient = ctx.createRadialGradient(CX - 20, CY - 20, 10, CX, CY, BASE_RADIUS + WOBBLE);
gradient.addColorStop(0, "rgba(134, 232, 255, 0.9)");
gradient.addColorStop(0.5, "rgba(61, 158, 255, 0.7)");
gradient.addColorStop(1, "rgba(174, 82, 255, 0.4)");
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
const p0 = pts[(i - 1 + pts.length) % pts.length];
const p1 = pts[i];
const p2 = pts[(i + 1) % pts.length];
const p3 = pts[(i + 2) % pts.length];
if (i === 0) {
ctx.moveTo(p1.x, p1.y);
}
// Catmull-Rom to cubic bezier control points
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
}
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Subtle inner glow
ctx.shadowColor = "rgba(134, 232, 255, 0.3)";
ctx.shadowBlur = 30;
ctx.fill();
ctx.shadowBlur = 0;
canvasRAF = requestAnimationFrame(drawBlob);
}
// Draw static blob for reduced motion
function drawStaticBlob() {
ctx.clearRect(0, 0, W, H);
const pts = blobPoints.map((p) => ({
x: CX + Math.cos(p.angle) * BASE_RADIUS,
y: CY + Math.sin(p.angle) * BASE_RADIUS,
}));
const gradient = ctx.createRadialGradient(CX - 20, CY - 20, 10, CX, CY, BASE_RADIUS);
gradient.addColorStop(0, "rgba(134, 232, 255, 0.9)");
gradient.addColorStop(0.5, "rgba(61, 158, 255, 0.7)");
gradient.addColorStop(1, "rgba(174, 82, 255, 0.4)");
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
const p0 = pts[(i - 1 + pts.length) % pts.length];
const p1 = pts[i];
const p2 = pts[(i + 1) % pts.length];
const p3 = pts[(i + 2) % pts.length];
if (i === 0) ctx.moveTo(p1.x, p1.y);
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
}
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
}
if (!reduced) {
canvasRAF = requestAnimationFrame(drawBlob);
} else {
drawStaticBlob();
}
// โโโ Motion Preference Toggle โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
window.addEventListener("motion-preference", (e) => {
reduced = e.detail.reduced;
document.documentElement.classList.toggle("reduced-motion", reduced);
if (reduced) {
// Stop SVG animation
cancelAnimationFrame(svgRAF);
// Stop canvas animation, draw static
cancelAnimationFrame(canvasRAF);
drawStaticBlob();
} else {
// Resume SVG
svgRAF = requestAnimationFrame(animateSVG);
// Resume canvas
canvasRAF = requestAnimationFrame(drawBlob);
}
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Morphing Blobs โ stealthisdesign</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="header">
<span class="eyebrow">Demo 20</span>
<h1>Morphing Blobs</h1>
<p class="subtitle">Three approaches to organic shape animation: CSS border-radius, SVG path morphing, and Canvas bezier curves.</p>
</header>
<main class="demos">
<!-- CSS Blobs -->
<section class="demo-block">
<h2>CSS Border-Radius</h2>
<p class="demo-desc">Animated 8-value border-radius creates organic blob shapes with pure CSS keyframes.</p>
<div class="blob-stage">
<div class="css-blob blob-1"></div>
<div class="css-blob blob-2"></div>
<div class="css-blob blob-3"></div>
</div>
</section>
<!-- SVG Morphing -->
<section class="demo-block">
<h2>SVG Path Morphing</h2>
<p class="demo-desc">Two SVG paths with matching point counts interpolated between states using coordinate lerping.</p>
<div class="blob-stage">
<svg class="svg-blob" viewBox="0 0 200 200">
<defs>
<linearGradient id="svg-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ae52ff"/>
<stop offset="100%" stop-color="#ff40d6"/>
</linearGradient>
</defs>
<path id="svg-morph-path" fill="url(#svg-grad)" d=""/>
</svg>
</div>
</section>
<!-- Canvas Blobs -->
<section class="demo-block">
<h2>Canvas Bezier Curves</h2>
<p class="demo-desc">Control points oscillate with sine waves at different frequencies, creating smooth organic motion.</p>
<div class="blob-stage">
<canvas id="canvas-blob" width="300" height="300" aria-hidden="true"></canvas>
</div>
</section>
</main>
<div class="footer">
<a href="/" class="btn-back">Back to Showcase</a>
</div>
<script src="script.js"></script>
</body>
</html>Morphing Blobs
Three approaches to organic shape animation: CSS border-radius, SVG path morphing, and Canvas bezier curves.
Source
- Repository:
libs-genclaude - Original demo id:
20-morphing-blobs
Notes
Three approaches to organic shape animation: CSS border-radius, SVG path morphing, and Canvas bezier curves.