effects#svg#animation
Morphing Blobs
Organic SVG shapes that continuously morph and flow with smooth bezier interpolation
Implementation Guide
# Morphing Blobs Effect
> Organic SVG shapes that continuously morph and flow with smooth bezier interpolation. Perfect for hero section backgrounds.
## Quick Start
```tsx
import MorphingBlobs from '@/components/effects/morphing-blobs';
export default function Hero() {
return (
<div className="relative h-screen">
<MorphingBlobs colors={['#007AFF', '#FF3B30', '#5856D6']} />
<div className="relative z-10">{/* Your content goes here */}</div>
</div>
);
}
```
## Props
| Prop | Type | Default | Description |
| ---------- | -------- | --------------------------------- | ---------------------------------------- |
| colors | string[] | ['#007AFF', '#FF3B30', '#5856D6'] | Array of hex colors for blobs |
| speed | number | 0.5 | Morphing animation speed (0.1 - 2.0) |
| complexity | number | 1 | Shape complexity/organicness (0.5 - 2.0) |
| blur | number | 0 | Optional CSS blur filter in pixels |
## Full Implementation
```tsx
'use client';
import { useEffect, useRef } from 'react';
interface MorphingBlobsProps {
colors?: string[];
speed?: number;
complexity?: number;
blur?: number;
}
// Represents a single morphing blob with its animation state
interface BlobState {
color: string;
baseRadius: number;
centerX: number;
centerY: number;
phaseOffset: number;
frequencyMultiplier: number;
}
// Generate smooth blob path using Fourier-like sine wave composition
function generateBlobPath(
centerX: number,
centerY: number,
baseRadius: number,
time: number,
phaseOffset: number,
frequencyMultiplier: number,
complexity: number
): string {
const points: { x: number; y: number }[] = [];
const numPoints = 64; // Number of points around the blob perimeter
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * Math.PI * 2;
// Combine multiple sine waves at different frequencies for organic shape
// Each wave contributes to the radius variation at this angle
let radiusVariation = 0;
// Primary wave - slow, large movement
radiusVariation +=
Math.sin(angle * 2 + time * frequencyMultiplier + phaseOffset) * 0.15 * complexity;
// Secondary wave - medium frequency
radiusVariation +=
Math.sin(angle * 3 + time * frequencyMultiplier * 1.3 + phaseOffset * 2) * 0.1 * complexity;
// Tertiary wave - higher frequency for detail
radiusVariation +=
Math.sin(angle * 5 + time * frequencyMultiplier * 0.7 + phaseOffset * 3) * 0.05 * complexity;
// Quaternary wave - subtle high-frequency ripples
radiusVariation +=
Math.sin(angle * 7 + time * frequencyMultiplier * 1.5 + phaseOffset * 4) * 0.03 * complexity;
const radius = baseRadius * (1 + radiusVariation);
points.push({
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
});
}
// Convert points to smooth bezier curve path
return createSmoothPath(points);
}
// Create a smooth closed bezier curve through the given points
function createSmoothPath(points: { x: number; y: number }[]): string {
if (points.length < 3) return '';
const pathSegments: string[] = [];
// Start at the first point
pathSegments.push(`M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`);
// Create smooth curves through all points using Catmull-Rom to Bezier conversion
for (let i = 0; i < points.length; i++) {
const previousPoint = points[(i - 1 + points.length) % points.length];
const currentPoint = points[i];
const nextPoint = points[(i + 1) % points.length];
const nextNextPoint = points[(i + 2) % points.length];
// Calculate control points for smooth bezier curve
const controlPoint1 = {
x: currentPoint.x + (nextPoint.x - previousPoint.x) / 6,
y: currentPoint.y + (nextPoint.y - previousPoint.y) / 6,
};
const controlPoint2 = {
x: nextPoint.x - (nextNextPoint.x - currentPoint.x) / 6,
y: nextPoint.y - (nextNextPoint.y - currentPoint.y) / 6,
};
pathSegments.push(
`C ${controlPoint1.x.toFixed(2)} ${controlPoint1.y.toFixed(2)}, ${controlPoint2.x.toFixed(2)} ${controlPoint2.y.toFixed(2)}, ${nextPoint.x.toFixed(2)} ${nextPoint.y.toFixed(2)}`
);
}
pathSegments.push('Z');
return pathSegments.join(' ');
}
export default function MorphingBlobs({
colors = ['#007AFF', '#FF3B30', '#5856D6'],
speed = 0.5,
complexity = 1,
blur = 0,
}: MorphingBlobsProps) {
const svgRef = useRef<SVGSVGElement>(null);
const blobsRef = useRef<BlobState[]>([]);
const pathRefs = useRef<(SVGPathElement | null)[]>([]);
const animationRef = useRef<number>(0);
const timeRef = useRef<number>(0);
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
// Initialize blob states with varied properties for visual interest
const initializeBlobs = () => {
const rect = svg.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
blobsRef.current = colors.map((color, index) => {
// Distribute blobs across the canvas with some overlap
const gridCols = Math.ceil(Math.sqrt(colors.length));
const gridRows = Math.ceil(colors.length / gridCols);
const col = index % gridCols;
const row = Math.floor(index / gridCols);
// Add some randomness to positions while keeping them distributed
const baseX = ((col + 0.5) / gridCols) * width;
const baseY = ((row + 0.5) / gridRows) * height;
const randomOffsetX = (Math.random() - 0.5) * width * 0.3;
const randomOffsetY = (Math.random() - 0.5) * height * 0.3;
return {
color,
// Vary base radius based on container size
baseRadius: Math.min(width, height) * (0.25 + Math.random() * 0.15),
centerX: baseX + randomOffsetX,
centerY: baseY + randomOffsetY,
// Different phase offsets create varied animation timing
phaseOffset: (index * Math.PI * 2) / colors.length + Math.random() * Math.PI,
// Slightly different speeds for each blob
frequencyMultiplier: 0.8 + Math.random() * 0.4,
};
});
};
// Animation loop
const animate = () => {
timeRef.current += 0.016 * speed; // Approximate 60fps timestep scaled by speed
blobsRef.current.forEach((blob, index) => {
const pathElement = pathRefs.current[index];
if (!pathElement) return;
const path = generateBlobPath(
blob.centerX,
blob.centerY,
blob.baseRadius,
timeRef.current,
blob.phaseOffset,
blob.frequencyMultiplier,
complexity
);
pathElement.setAttribute('d', path);
});
animationRef.current = requestAnimationFrame(animate);
};
// Handle resize
const handleResize = () => {
initializeBlobs();
};
// Check for reduced motion preference
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
initializeBlobs();
if (!prefersReducedMotion) {
animate();
} else {
// Still render initial state for reduced motion users
blobsRef.current.forEach((blob, index) => {
const pathElement = pathRefs.current[index];
if (!pathElement) return;
const path = generateBlobPath(
blob.centerX,
blob.centerY,
blob.baseRadius,
0, // Static time
blob.phaseOffset,
blob.frequencyMultiplier,
complexity
);
pathElement.setAttribute('d', path);
});
}
window.addEventListener('resize', handleResize);
return () => {
cancelAnimationFrame(animationRef.current);
window.removeEventListener('resize', handleResize);
};
}, [colors, speed, complexity]);
// Convert hex color to rgba for gradient stops
const hexToRgba = (hex: string, alpha: number): string => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return `rgba(0, 0, 0, ${alpha})`;
return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
};
return (
<div
className="h-full w-full"
style={{
background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)',
}}
>
<svg
ref={svgRef}
className="h-full w-full"
style={{
filter: blur > 0 ? `blur(${blur}px)` : undefined,
}}
preserveAspectRatio="xMidYMid slice"
>
<defs>
{/* Define gradients and glow filters for each blob */}
{colors.map((color, index) => (
<radialGradient
key={`gradient-${index}`}
id={`blob-gradient-${index}`}
cx="50%"
cy="50%"
r="50%"
fx="30%"
fy="30%"
>
<stop offset="0%" stopColor={hexToRgba(color, 0.8)} />
<stop offset="50%" stopColor={hexToRgba(color, 0.4)} />
<stop offset="100%" stopColor={hexToRgba(color, 0.1)} />
</radialGradient>
))}
{/* Glow filter for enhanced visual effect */}
<filter id="blob-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="20" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Render blob paths with blend modes for interesting color interactions */}
<g style={{ mixBlendMode: 'screen' }}>
{colors.map((_, index) => (
<path
key={`blob-${index}`}
ref={(el) => {
pathRefs.current[index] = el;
}}
fill={`url(#blob-gradient-${index})`}
filter="url(#blob-glow)"
style={{ mixBlendMode: 'screen' }}
/>
))}
</g>
</svg>
</div>
);
}
```
## Customization
### Brand Colors
Replace the `colors` array with your brand palette:
```tsx
<MorphingBlobs colors={['#yourPrimary', '#yourSecondary', '#yourAccent']} />
```
### Subtle Background
For a more subtle, ambient effect suitable for landing pages:
```tsx
<MorphingBlobs colors={['#007AFF', '#5856D6']} speed={0.3} complexity={0.6} blur={40} />
```
### High Energy / Lava Lamp
For a more dynamic, energetic feel with complex organic shapes:
```tsx
<MorphingBlobs colors={['#FF3B30', '#FF9500', '#FFCC00', '#FF6B6B']} speed={1.2} complexity={1.5} />
```
### Monochrome Gradient
For a sophisticated single-color effect:
```tsx
<MorphingBlobs colors={['#007AFF', '#0055CC', '#003399']} speed={0.4} complexity={0.8} />
```
### Multiple Blobs
Add more colors for a richer, more layered effect:
```tsx
<MorphingBlobs
colors={['#007AFF', '#FF3B30', '#5856D6', '#FF9500', '#34C759']}
speed={0.5}
complexity={1.0}
/>
```
### Dark vs Light Backgrounds
The component uses a dark gradient by default. For light backgrounds, wrap the component and override the background:
```tsx
<div
className="relative h-screen"
style={{ background: 'linear-gradient(135deg, #f5f5f5 0%, #e0e0e0 100%)' }}
>
<div className="absolute inset-0 mix-blend-multiply">
<MorphingBlobs colors={['#007AFF', '#FF3B30']} />
</div>
<div className="relative z-10">{/* Content */}</div>
</div>
```
## How It Works
### Path Generation
The morphing effect is achieved through a Fourier-like composition of sine waves:
1. **Base Shape**: Each blob starts as a circle defined by `baseRadius`
2. **Wave Composition**: Multiple sine waves at different frequencies are combined to create organic deformations
3. **Bezier Interpolation**: The resulting points are connected using Catmull-Rom to Bezier curve conversion for smooth paths
### Animation Timing
- Each blob has a unique `phaseOffset` to prevent synchronized movement
- The `frequencyMultiplier` varies slightly per blob for natural desynchronization
- Time advances at 60fps with the `speed` prop scaling the rate
### Visual Effects
- **Radial Gradients**: Each blob has a radial gradient from center (opaque) to edge (transparent)
- **Glow Filter**: SVG `feGaussianBlur` creates a soft glow around each blob
- **Screen Blend Mode**: Blobs blend additively for beautiful color mixing where they overlap
## Performance Considerations
- **SVG vs Canvas**: Uses SVG for crisp rendering at any resolution and simpler gradient/filter definitions
- **Path Complexity**: 64 points per blob provides smooth curves while remaining performant
- **Reduce blob count** on mobile devices (2-3 recommended vs 4-5 on desktop)
- **Avoid high blur values** on mobile as CSS filters can be GPU-intensive
- Use `will-change: transform` on the parent container for smoother compositing
- The component automatically handles window resize events
## Accessibility
- **Reduced Motion**: Respects `prefers-reduced-motion` - blobs render in static state when user prefers reduced motion
- **Decorative Only**: The effect is purely decorative - ensure content above has proper contrast
- **Content Layering**: Place content with `position: relative; z-index: 10` to appear above the SVG
- **No Interaction**: Blobs don't respond to mouse/touch, keeping the effect non-distracting
## Browser Support
- Modern browsers with SVG support (all major browsers)
- SVG filters (`feGaussianBlur`, `feMerge`) supported in Chrome 8+, Firefox 3+, Safari 6+, Edge 12+
- CSS `mix-blend-mode` supported in Chrome 41+, Firefox 32+, Safari 8+, Edge 79+
- `requestAnimationFrame` support required (all modern browsers)
- Falls back gracefully in older browsers (static shapes without glow)