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