effects#loader#spinner

Liquid Loader

A blob-based loading spinner where circles merge and separate like liquid

Implementation Guide
# Liquid Loader

> A blob-based loading spinner where multiple circles merge and separate like liquid. Satisfying, organic alternative to traditional spinners.

## Quick Start

```tsx
import LiquidLoader from '@/components/examples/effects/liquid-loader';

export default function LoadingState() {
  return (
    <div className="h-64 w-64">
      <LiquidLoader color="#007AFF" size={120} />
    </div>
  );
}
```

## Props

| Prop      | Type   | Default   | Description                            |
| --------- | ------ | --------- | -------------------------------------- |
| size      | number | 120       | Loader size in pixels                  |
| color     | string | '#007AFF' | Blob color (any valid CSS color)       |
| speed     | number | 1         | Animation speed multiplier (0.5 - 2.0) |
| blobCount | number | 4         | Number of orbiting blobs (2 - 8)       |

## Full Implementation

```tsx
'use client';

import { useEffect, useId, useMemo, useRef } from 'react';

interface LiquidLoaderProps {
  size?: number;
  color?: string;
  speed?: number;
  blobCount?: number;
}

interface BlobConfig {
  orbitRadius: number;
  orbitSpeed: number;
  blobRadius: number;
  initialAngle: number;
}

export default function LiquidLoader({
  size = 120,
  color = '#007AFF',
  speed = 1,
  blobCount = 4,
}: LiquidLoaderProps) {
  const filterIdSuffix = useId();
  const svgRef = useRef<SVGSVGElement>(null);
  const animationRef = useRef<number>(0);
  const anglesRef = useRef<number[]>([]);

  const centerX = size / 2;
  const centerY = size / 2;
  const filterId = `gooey-filter${filterIdSuffix}`;

  const blobConfigs = useMemo<BlobConfig[]>(
    () =>
      Array.from({ length: blobCount }, (_, index) => ({
        initialAngle: (index / blobCount) * Math.PI * 2,
        orbitRadius: size * 0.15 + (index % 2) * size * 0.08,
        orbitSpeed: 0.8 + (index % 3) * 0.3,
        blobRadius: size * 0.12 + (index % 2) * size * 0.04,
      })),
    [blobCount, size]
  );

  // Initialize angles when configs change
  useEffect(() => {
    anglesRef.current = blobConfigs.map((config) => config.initialAngle);
  }, [blobConfigs]);

  useEffect(() => {
    const svg = svgRef.current;
    if (!svg) return;

    const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (prefersReducedMotion) return;

    const circles = svg.querySelectorAll<SVGCircleElement>('.orbit-blob');

    const animate = () => {
      anglesRef.current = anglesRef.current.map((angle, index) => {
        const config = blobConfigs[index];
        return angle + (config?.orbitSpeed ?? 1) * speed * 0.03;
      });

      circles.forEach((circle, index) => {
        const config = blobConfigs[index];
        if (!config) return;
        const angle = anglesRef.current[index] ?? config.initialAngle;
        const cx = centerX + Math.cos(angle) * config.orbitRadius;
        const cy = centerY + Math.sin(angle) * config.orbitRadius;
        circle.setAttribute('cx', String(cx));
        circle.setAttribute('cy', String(cy));
      });

      animationRef.current = requestAnimationFrame(animate);
    };

    animationRef.current = requestAnimationFrame(animate);

    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
    const handleChange = (event: MediaQueryListEvent) => {
      if (event.matches) {
        cancelAnimationFrame(animationRef.current);
      } else {
        animationRef.current = requestAnimationFrame(animate);
      }
    };
    mediaQuery.addEventListener('change', handleChange);

    return () => {
      cancelAnimationFrame(animationRef.current);
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, [blobConfigs, speed, centerX, centerY]);

  // Initial blob positions for SSR and reduced motion
  const initialPositions = blobConfigs.map((config) => ({
    cx: centerX + Math.cos(config.initialAngle) * config.orbitRadius,
    cy: centerY + Math.sin(config.initialAngle) * config.orbitRadius,
    r: config.blobRadius,
  }));

  return (
    <div
      className="flex h-full w-full items-center justify-center"
      style={{ background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)' }}
    >
      <svg
        ref={svgRef}
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        role="img"
        aria-label="Loading"
      >
        <defs>
          {/* Gooey/metaball filter - creates liquid merging effect */}
          <filter id={filterId}>
            {/* Blur the shapes to make them soft */}
            <feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur" />
            {/* Increase contrast to create sharp edges where blobs merge */}
            <feColorMatrix
              in="blur"
              mode="matrix"
              values="1 0 0 0 0
                      0 1 0 0 0
                      0 0 1 0 0
                      0 0 0 25 -10"
              result="goo"
            />
            {/* Composite the original shape back for crisp edges */}
            <feComposite in="SourceGraphic" in2="goo" operator="atop" />
          </filter>
        </defs>

        {/* Group with gooey filter applied */}
        <g filter={`url(#${filterId})`}>
          {/* Center blob */}
          <circle cx={centerX} cy={centerY} r={size * 0.18} fill={color} />

          {/* Orbiting blobs */}
          {initialPositions.map((blob, index) => (
            <circle
              key={index}
              className="orbit-blob"
              cx={blob.cx}
              cy={blob.cy}
              r={blob.r}
              fill={color}
            />
          ))}
        </g>
      </svg>
    </div>
  );
}
```

## Customization

### Different Sizes

```tsx
// Small - inline or button loading state
<LiquidLoader size={32} blobCount={3} />

// Medium - card loading state
<LiquidLoader size={80} />

// Large - full page loading
<LiquidLoader size={200} blobCount={6} />
```

### Brand Colors

```tsx
// Primary brand color
<LiquidLoader color="#007AFF" />

// Accent/alert color
<LiquidLoader color="#FF3B30" />

// Success state
<LiquidLoader color="#34C759" />

// Using CSS variables
<LiquidLoader color="var(--color-primary)" />
```

### Animation Speed

```tsx
// Slow, relaxed loading
<LiquidLoader speed={0.5} />

// Normal speed
<LiquidLoader speed={1} />

// Fast, urgent loading
<LiquidLoader speed={2} />
```

### Blob Count Variations

```tsx
// Minimal - 2 blobs
<LiquidLoader blobCount={2} />

// Default - 4 blobs
<LiquidLoader blobCount={4} />

// Complex - 6+ blobs for more liquid effect
<LiquidLoader blobCount={6} />
```

### Light Background Variant

To use on light backgrounds, wrap the component and override the background:

```tsx
<div className="flex h-64 w-64 items-center justify-center bg-white">
  <div style={{ background: 'transparent' }}>
    <LiquidLoader color="#111111" />
  </div>
</div>
```

Or modify the component's inline style for light mode support.

## How It Works

The liquid/gooey effect is achieved using an SVG filter chain:

1. **feGaussianBlur**: Softens the edges of all circles
2. **feColorMatrix**: Increases alpha contrast, creating sharp edges where blurred shapes overlap
3. **feComposite**: Combines the filtered result with the original shapes

The matrix values `0 0 0 25 -10` in the alpha channel (4th row) control the "gooeyness":

- Higher first value (25) = sharper blob edges
- Lower second value (-10) = more aggressive merging threshold

## Performance Considerations

- **SVG filters are GPU-accelerated** in modern browsers
- **Avoid excessive blob counts** - 4-6 blobs is optimal for smooth performance
- **Consider reducing speed** on lower-powered devices
- **The filter is applied once** to the entire group, not per-blob
- Use `will-change: transform` on parent containers if compositing issues occur

## Accessibility

- Includes `role="img"` and `aria-label="Loading"` for screen readers
- **Respects `prefers-reduced-motion`** - shows static blob positions when enabled
- For production use, consider adding visually-hidden loading text:

```tsx
<div className="relative">
  <LiquidLoader />
  <span className="sr-only">Loading, please wait...</span>
</div>
```

## Browser Support

- **Modern browsers**: Full support (Chrome, Firefox, Safari, Edge)
- **SVG filters**: Supported in all modern browsers
- **feColorMatrix**: Wide support, including mobile browsers
- **requestAnimationFrame**: Required for animation, supported everywhere

### Known Limitations

- Safari may render the gooey effect slightly differently due to filter implementation
- Very old browsers without SVG filter support will show plain circles without the merging effect