
Source: https://unsplash.com/photos/a-large-blue-balloon-iWBSbcVN0ZI
Blobs. Blobby blobs. Morphing, glowing, fluid animations that feel organic, alive, and endlessly mesmerising. You’ve probably seen them in onboarding screens, live stream overlays, music visualisers, or background gradients that gently breathe.
In this article, we’ll walk through the process of building one from scratch using Jetpack Compose’s Canvas, Path, and Animatable. We’ll start from the very basics of control point-based shape drawing, and iterate step by step to build a breathing, glowing morphing blob using Bézier curves and animated geometry.
Let’s build your own morphing blob animation.
🌀 Step 1: Build the Base Circle with Control Points
Every morphing blob starts with a foundation.
Here, we’ll place evenly spaced anchor points around a circle. These points act as the skeleton of the blob, later steps will bend and animate them to create organic motion.
val morphPoints = 8
val xs = FloatArray(morphPoints)
val ys = FloatArray(morphPoints)
val angleOffset = (2 * PI / morphPoints * Math.random()).toFloat()
for (i in 0 until morphPoints) {
val angle = (2 * PI / morphPoints * i).toFloat() + angleOffset
val radius = baseRadius
xs[i] = center.x + radius * cos(angle)
ys[i] = center.y + radius * sin(angle)
}
This snippet distributes morphPoints evenly around the circle, giving us a perfectly circular set of anchor points. These anchors act as the foundation for our shape and will serve as the building blocks for the smooth curves we’ll draw in the next step.

Evenly spaced anchor points form the base geometry of the blob
🪢 Step 2: Connecting the Points with Bézier Curves
A Cubic Bézier Curve is a smooth curve defined by four points. The curve starts at P₁, heads toward C₁, bends toward C₂, and arrives at P₂. By adjusting C₁ and C₂, we control the “pull” and “tension” of the curve.
P1 *
\
\
* C1
|
|
* C2
/
/
P2 *
In our blob, each anchor point on the circle is an end point (P₁ / P₂). The control points (C₁ / C₂) are calculated along the tangent direction of the anchor so that the curve flows naturally through the shape.
✨ Why we use it here:
If we simply connected anchors with straight lines, we’d get a jagged polygon. Even Quadratic Béziers (one control point) wouldn’t give us enough flexibility.
Cubic Béziers (two control points per segment):
- Let us fine-tune curvature between anchors.
- Ensure continuous, flowing edges with no sharp corners.
- Give us a smooth organic outline, which is exactly the feel of a living, morphing blob.
This is the key step that transforms “a circle of dots” into “an organic, wave-like shape.”
To compute the direction and intensity of those curves, we use the following helper:
private const val LINE_SMOOTHNESS = 0.16f
private fun FloatArray.circular(index: Int): Float {
if (isEmpty()) error("Array cannot be empty")
return this[(index % size + size) % size]
}
private fun getVector(xs: FloatArray, ys: FloatArray, i: Int): Offset {
val next = Offset(xs.circular(i + 1), ys.circular(i + 1))
val prev = Offset(xs.circular(i - 1), ys.circular(i - 1))
return (next - prev) * LINE_SMOOTHNESS
}
LINE_SMOOTHNESScontrols how “curvy” the blob edges are.circular()allows wraparound access to any index as if the array is circular.getVector()computes a tangent vector at anchor pointiusing the direction between its neighbours.
With these in place, we build the full Path of our blob:
val path = Path()
path.moveTo(xs[0], ys[0])
for (i in 0 until morphPoints) {
val curr = Offset(xs.circular(i), ys.circular(i))
val next = Offset(xs.circular(i + 1), ys.circular(i + 1))
val v1 = getVector(xs, ys, i)
val v2 = getVector(xs, ys, i + 1)
path.cubicTo(
curr.x + v1.x, curr.y + v1.y,
next.x - v2.x, next.y - v2.y,
next.x, next.y
)
}

🔄 Step 3: Animating the Radius of Each Point
Up until now, our anchors have been fixed on a circle’s circumference, which means the blob shape is static. To give it life, we let each anchor breathe by animating its distance (radius) from the center slightly inward and outward over time.
In Jetpack Compose, we use Animatable to smoothly transition each anchor’s radius.
// Animating radius fractions
val radiusAnimValues = remember { List(morphPoints) { Animatable(Random.nextFloat()) } }
val fractions = remember { FloatArray(morphPoints + 1) { it.toFloat() / morphPoints } }
LaunchedEffect(Unit) {
radiusAnimValues.forEach { animatable ->
launch {
while (true) {
val target = generateDestFractions(fractions).random()
animatable.animateTo(
targetValue = target,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = durationMillis,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
)
)
}
}
}
}
// Later, when computing anchor positions (Step 1)
for (i in 0 until morphPoints) {
val angle = (2 * PI / morphPoints * i).toFloat() + angleOffset
val t = radiusAnimValues[i].value
xs[i] = center.x + radius * cos(angle)
ys[i] = center.y + radius * sin(angle)
}
// helper function
fun generateDestFractions(fractions: FloatArray): List<Float> {
if (fractions.isEmpty()) return emptyList()
val startIndex = (fractions.indices).random()
return buildList {
addAll(fractions.slice(startIndex until fractions.size))
addAll(fractions.slice(0 until startIndex).reversed())
}
}
- Each anchor gets its own
Animatable, producing independent, organic-looking motion. generateDestFractions()introduces variation by shuffling the oscillation pattern across anchors.
🧭 Step 4: Subtle Translation for Life-like Movement
To make the blob feel alive like it’s floating, breathing, or gently drifting. We apply a subtle translation to the blob’s center. The movement is generated using trigonometric updates and damping to ensure smooth, natural motion.
val translationCenter = remember { mutableStateOf(Offset.Zero) }
val lastTranslationAngle = remember { mutableStateOf(0f) }
LaunchedEffect(canvasSize) {
val width = canvasSize.width.toFloat()
val height = canvasSize.height.toFloat()
val outerRadius = (min(width, height) / 2f)
val r = outerRadius / 4000f // small incremental step for smooth movement
val outR = outerRadius / 6f // maximum offset from center
val cx = width / 2f
val cy = height / 2f
while (true) {
delay(30) // ~30 FPS
// Compute damped offset from geometric center
val vx = translationCenter.value.x - cx
val vy = translationCenter.value.y - cy
val ratio = 1 - r / outR
val wx = vx * ratio
val wy = vy * ratio
// Slightly randomize movement angle for natural drifting
lastTranslationAngle.value =
((Math.random() - 0.5) * Math.PI / 4 + lastTranslationAngle.value).toFloat()
// Random distance multiplier
val distRatio = Math.random().toFloat()
// Update the blob's center with damped offset + random nudge
translationCenter.value = Offset(
cx + wx + r * distRatio * cos(lastTranslationAngle.value.toDouble()).toFloat(),
cy + wy + r * distRatio * sin(lastTranslationAngle.value.toDouble()).toFloat()
)
}
}
Here’s a updated version of the Canvas with Step 3 animated radii and Step 4 floating center integrated:
Canvas(
modifier = modifier
.onSizeChanged { size ->
// Initialize translation center to geometric center
translationCenter.value = Offset(size.width / 2f, size.height / 2f)
}
) {
val width = size.width
val height = size.height
val cx = translationCenter.value.x
val cy = translationCenter.value.y
val outerRadius = min(width, height) / 2f
val innerRadius = outerRadius * 0.75f
val ringWidth = outerRadius - innerRadius
val xs = FloatArray(morphPoints)
val ys = FloatArray(morphPoints)
val paint = Paint().asFrameworkPaint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.STROKE
strokeWidth = strokeWidth * density
}
// Step 3: Animate each anchor radius
for (i in 0 until morphPoints) {
val t = radiusAnimValues[i].value // animated fraction
val r = innerRadius + ringWidth * t // radius for this point
val angle = (2 * Math.PI / morphPoints * i).toFloat() + angleOffset
xs[i] = cx + r * cos(angle.toDouble()).toFloat()
ys[i] = cy + r * sin(angle.toDouble()).toFloat()
}
// Step 2: Build smooth Bézier path
path.reset()
path.moveTo(xs[0], ys[0])
for (i in 0 until morphPoints) {
val curr = Offset(xs.circular(i), ys.circular(i))
val next = Offset(xs.circular(i + 1), ys.circular(i + 1))
val v1 = getVector(xs, ys, i)
val v2 = getVector(xs, ys, i + 1)
path.cubicTo(
curr.x + v1.x, curr.y + v1.y,
next.x - v2.x, next.y - v2.y,
next.x, next.y
)
}
// Draw blob with floating center
drawIntoCanvas {
it.nativeCanvas.drawPath(path.asAndroidPath(), paint)
}
}

Floating, Breathing, & Gently Drifting
🎨 Step 5: Paint, Blur & Shader Styling
To make the blob visually appealing, we can style it with:
- Fill or stroke
- Gradients or shaders
- Blur effects for softness
This styling is applied via a Paint object on the Canvas.
val paint = Paint().asFrameworkPaint().apply {
isAntiAlias = true // Smooth edges
// Fill or stroke based on blob style
style = when (blobStyle.shape) {
is BlobShape.Fill -> Paint.Style.FILL
is BlobShape.Stroke -> Paint.Style.STROKE.also {
strokeWidth = blobStyle.shape.strokeWidth * density
}
}
// Optional shader (gradient, radial, etc.)
shader = blobStyle.shader.toAndroidShader(
width = size.width,
height = size.height
)
// Optional blur effect for softer edges
maskFilter = BlurMaskFilter(
blobStyle.effect.blurRadius.coerceAtLeast(1f) * density,
BlurMaskFilter.Blur.NORMAL
)
}
Job Offers
🧪 Final Result: Your Own Morphing Blob

Experience the full effect of our morphing blob with all the techniques combined:
- Circular Point Layout: Points are arranged in a circle for a smooth, symmetric base shape.
- Bézier-based Connection: Points are connected with Bézier curves to create organic, flowing outlines.
- Per-Point Animated Morphing: Each point subtly animates independently, producing a natural morphing effect.
- Organic Center Translation: The blob’s center gently moves to enhance the sense of fluidity.
- Gradient + Blur Styling: Apply gradients, fills, strokes, and soft blur to give depth and polish to the visual.
🎁 Bonus: Real UI Inspiration
Why stop at simple blobs? While crafting this morphing animation, I stumbled upon a beautifully designed fitness app concept on Dribbble that deeply inspired this article:
👉 Fitness Mobile App: Everyday Workout
Imagine this: background blobs that breathe in and out, guiding the user through a “Jumping Jack” session. By layering multiple MorphingBlobs with different alpha values, stroke widths, and animation timings, you can create a calming, organic atmosphere that feels alive.

You can find the complete code for this article here: Gist
📚 Wrapping Up
From geometry and Bézier curves to per-point animation and visual polish, we’ve built a delightful, dynamic component. The key lies in modularity and control points, which give you full creative freedom.
Here are some ideas to take it even further:
- Sound reactivity: Let the blobs dance to music or notifications.
- Touch-based deformation: Interact with the blob directly via gestures.
- Perlin noise perturbation: Add more natural, unpredictable movement.
- Dynamic theme-driven coloring: Match the blob’s look to your app’s theme.
Until next time, happy blobbing! 🫧
This article was previously published on proandroiddev.com


