Implementation of Liquid/Blob Effects on Website
Blob and liquid effects are organic, "living" forms: blots with smoothly changing edges, liquid transitions between states, geometry deformation by noise. In web this is implemented via SVG (simple cases), CSS filters (blur+contrast trick), Canvas and WebGL (full control).
SVG Blob with Animated Path
Basic variant: SVG shape with animation of control points via JS.
// Generate blob path via cubicBezier control points
function createBlobPath(
cx: number,
cy: number,
radius: number,
points: number,
variance: number,
seed: number
): string {
const angleStep = (Math.PI * 2) / points
const coords: [number, number][] = []
for (let i = 0; i < points; i++) {
const angle = i * angleStep - Math.PI / 2
const r = radius + (Math.random() * variance * 2 - variance)
coords.push([
cx + Math.cos(angle) * r,
cy + Math.sin(angle) * r,
])
}
// Build smooth path via catmull-rom -> bezier
const d: string[] = []
const n = coords.length
for (let i = 0; i < n; i++) {
const p0 = coords[(i - 1 + n) % n]
const p1 = coords[i]
const p2 = coords[(i + 1) % n]
const p3 = coords[(i + 2) % n]
const cp1x = p1[0] + (p2[0] - p0[0]) / 6
const cp1y = p1[1] + (p2[1] - p0[1]) / 6
const cp2x = p2[0] - (p3[0] - p1[0]) / 6
const cp2y = p2[1] - (p3[1] - p1[1]) / 6
if (i === 0) {
d.push(`M ${p1[0]},${p1[1]}`)
}
d.push(`C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`)
}
d.push('Z')
return d.join(' ')
}
// Animation: morph between two blob shapes
class AnimatedBlob {
private path: SVGPathElement
private pathA: string
private pathB: string
private progress = 0
private direction = 1
private speed = 0.005
constructor(path: SVGPathElement, cx: number, cy: number, radius: number) {
this.path = path
this.pathA = createBlobPath(cx, cy, radius, 8, radius * 0.25, 1)
this.pathB = createBlobPath(cx, cy, radius, 8, radius * 0.25, 2)
this.animate()
}
private animate() {
this.progress += this.speed * this.direction
if (this.progress >= 1 || this.progress <= 0) this.direction *= -1
// GSAP morphSVG or native interpolator
this.path.setAttribute('d', this.interpolatePaths(
this.pathA,
this.pathB,
this.progress
))
requestAnimationFrame(() => this.animate())
}
}
Easier to use GSAP MorphSVGPlugin (Club GreenSock):
import gsap from 'gsap'
import MorphSVGPlugin from 'gsap/MorphSVGPlugin'
gsap.registerPlugin(MorphSVGPlugin)
// Morph between paths
gsap.to('#blob-path', {
morphSVG: '#blob-path-b',
duration: 3,
ease: 'sine.inOut',
repeat: -1,
yoyo: true,
})
CSS Blob via filter: blur + contrast
Cheap but effective trick. Multiple circles with blur, wrapped in container with contrast(20). On boundaries of blurred overlapping circles, liquid sticking occurs.
<div class="blob-container">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
<div class="blob blob--3"></div>
<div class="blob blob--cursor"></div>
</div>
.blob-container {
position: fixed;
inset: 0;
filter: blur(40px) contrast(20);
/* contrast() — key to the effect */
}
.blob {
position: absolute;
border-radius: 50%;
background: #7000ff;
}
.blob--1 {
width: 300px;
height: 300px;
top: 20%;
left: 30%;
animation: blob-float-1 8s ease-in-out infinite alternate;
}
.blob--2 {
width: 200px;
height: 200px;
top: 50%;
left: 60%;
animation: blob-float-2 10s ease-in-out infinite alternate;
}
.blob--3 {
width: 250px;
height: 250px;
top: 70%;
left: 20%;
animation: blob-float-3 12s ease-in-out infinite alternate;
}
@keyframes blob-float-1 {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(80px, -60px) scale(1.1); }
100% { transform: translate(-40px, 40px) scale(0.9); }
}
@keyframes blob-float-2 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(-100px, 80px) scale(1.2); }
}
@keyframes blob-float-3 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(60px, -80px) scale(0.8); }
}
Blob-cursor — follows the mouse:
const blobCursor = document.querySelector('.blob--cursor')
let mouseX = 0, mouseY = 0
let currentX = 0, currentY = 0
document.addEventListener('mousemove', (e) => {
mouseX = e.clientX
mouseY = e.clientY
})
function animateCursor() {
currentX += (mouseX - currentX) * 0.08
currentY += (mouseY - currentY) * 0.08
blobCursor.style.transform = `translate(${currentX - 75}px, ${currentY - 75}px)`
requestAnimationFrame(animateCursor)
}
animateCursor()
WebGL Liquid via Shader
Full control over shape, color, behavior via GLSL. Shader based on sdf (signed distance function):
// Fragment shader — liquid metaballs
uniform float uTime;
uniform vec2 uMouse;
uniform vec2 uResolution;
// SDF for circle
float circle(vec2 p, vec2 center, float r) {
return length(p - center) - r;
}
// Smooth union — "sticking" of shapes
float smoothUnion(float d1, float d2, float k) {
float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
return mix(d2, d1, h) - k * h * (1.0 - h);
}
void main() {
vec2 uv = (gl_FragCoord.xy - uResolution * 0.5) / min(uResolution.x, uResolution.y);
vec2 mouse = (uMouse - uResolution * 0.5) / min(uResolution.x, uResolution.y);
// Three blobs with independent movement
vec2 p1 = vec2(sin(uTime * 0.7) * 0.3, cos(uTime * 0.5) * 0.2);
vec2 p2 = vec2(cos(uTime * 0.4) * 0.25, sin(uTime * 0.8) * 0.25);
vec2 p3 = mouse * 0.5; // follows cursor
float d1 = circle(uv, p1, 0.18);
float d2 = circle(uv, p2, 0.14);
float d3 = circle(uv, p3, 0.12);
float merged = smoothUnion(smoothUnion(d1, d2, 0.08), d3, 0.06);
// Color: gradient inside form, rim-lighting on edges
vec3 colorInner = vec3(0.4, 0.0, 1.0);
vec3 colorRim = vec3(0.0, 0.8, 1.0);
float fill = smoothstep(0.005, -0.005, merged);
float rim = smoothstep(0.02, 0.0, merged) - smoothstep(0.0, -0.02, merged);
vec3 color = mix(vec3(0.0), colorInner, fill);
color += colorRim * rim * 0.8;
gl_FragColor = vec4(color, fill + rim * 0.5);
}
React Component with CSS Blob
import { useEffect, useRef } from 'react'
export function LiquidBackground() {
const containerRef = useRef<HTMLDivElement>(null)
const cursorBlobRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const cursor = cursorBlobRef.current
if (!cursor) return
let mx = 0, my = 0, cx = 0, cy = 0
let rafId: number
const onMove = (e: MouseEvent) => { mx = e.clientX; my = e.clientY }
window.addEventListener('mousemove', onMove)
const tick = () => {
cx += (mx - cx) * 0.08
cy += (my - cy) * 0.08
cursor.style.transform = `translate(${cx - 75}px, ${cy - 75}px)`
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
return () => {
window.removeEventListener('mousemove', onMove)
cancelAnimationFrame(rafId)
}
}, [])
return (
<div ref={containerRef} className="blob-container">
<div className="blob blob--1" />
<div className="blob blob--2" />
<div ref={cursorBlobRef} className="blob blob--cursor" />
</div>
)
}
Timeframes
CSS blob with cursor-following — 1 day. SVG morphing via GSAP — 2–3 days. WebGL metaballs with custom shader and interactivity — 5–7 days.







