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)