A Gooey Challenge, Met With Paint
Creating organic, gooey blob shapes has always been a challenge in CSS. The standard approach is to fall back on SVG for these kinds of morphing forms. However, the CSS Paint API offers a new path that can handle these complex visuals with pure CSS logic.
The premise is to calculate a blob-like path directly in the paint worklet. By using a set of points and a smoothing function, we can generate a closed, continuous curve that looks organic and fluid. This removes the need for external image assets or inline SVG data.
Building the Blob Path
The core of the effect is a function that takes a set of coordinates and combines them into a smooth, continuous curve. Instead of connecting points with straight lines, we introduce control points to bend the path. Each segment between two points is treated as a curve mediated by the previous and next points in the sequence, creating a natural, gooey outline.
function drawBlob(ctx, points) { ... } is the key utility. It iterates through the points array, calculates the midpoints between consecutive vertices, and then draws quadratic curves to those midpoints using the original vertices as control points. This effectively turns a jagged polygon into a smooth, continuous loop.
// Pseudo-code from the source implementation
for (let i = 0; i < points.length; i++) {
let p1 = points[i];
let p2 = points[(i + 1) % points.length];
let midPoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
if (i === 0) { ctx.moveTo(midPoint.x, midPoint.y); }
else { ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y); }
}
ctx.closePath();
To turn this static shape into a moving blob, the worklet needs to process an animation frame. We can achieve this by overwriting a property that the worklet listens to, forcing a repaint each frame. This property, like --blob-animation, acts as a clock. Its value is irrelevant; what matters is that it is updated on every frame via requestAnimationFrame. Each update triggers the paint() function, which recalculates the blob at a new state.
Driving the Motion
The animation itself comes from adjusting the radius of the blob based on time. In the main thread, the code sets an initial radius and then uses a requestAnimationFrame loop to increment a counter. That counter can be passed to the worklet as a CSS custom property (e.g., --blob-animation). Flipping the value each frame serves as a signal to the browser that the paint needs to be invalidated.
In the worklet, the core logic relies on a dedicated function, generateBlobPoints(). It creates a series of points around a circle, but instead of a fixed radius, each point has a dynamic radius derived from a mathematical function. The number of edges is defined by EDGE_COUNT for performance on lower-end devices, but it can be increased for higher fidelity on machines with more power.
Inside the loop, each point is placed based on a wave pattern. The core logic for the wave amplitude is:
rz = Math.sin(i * 0.5 + time * 0.02);
This creates a ripple effect, but choosing a high frequency (like multiplying the index by 5) can generate an aggressive and chaotic look, more like a mess of tentacles. The pace of the animation is controlled by the delta mean value in the main thread. A lower delta slows the transition between blobs.
The result is a lightweight, pure-CSS solution that opens the door for advanced organic animations that were previously difficult to author without SVG. The full code and a working demo are available in the provided codepen.
Building a Morphing Blob with the CSS Paint API
A blob is essentially a distorted circle. To draw one in a <canvas>, we start by placing a series of points (N) evenly around a circle’s circumference. Using basic trigonometry based on the center point (CenterX and CenterY) and the radius, we can find the coordinates for these points.
To connect these points with a smooth curve, we use cubic Bézier curves. This requires defining additional points—specifically, a start point, a control point, and an end point for each segment. The simplest approach is to position the start and end points at the midpoints between our primary (control) points.
We can then distort the circle by moving the control points. If we adjust a single point's position to be closer to the center, the cubic Bézier curve will follow, creating a smooth bump. When we apply a random offset to each point's distance from the center, the result is the classic, organic blob shape.
Transitioning to the Paint API
The CSS Paint API lets us use this blob shape as a mask on an image. Since the shape is circular, it makes sense to work with square elements where the radius is half the size of the element. A CSS variable, N, controls the number of points used to define the shape's granularity.
Animating this shape is where things get interesting. The core idea is to smoothly transition the position of some or all of the points to move between two defined shapes. For instance, we can animate from a circle to a blob by moving a few designated points.
We achieve this by introducing another CSS variable, B, which is tied to a CSS transition. Inside the paint() function, we read this variable and use it to compute the point's position—e.g., moving it from a radius (RADIUS) to a new distance (RADIUS - B). By changing the value of B from 0 to 100 on hover, we shift the point towards the center.
The initial effect focuses on a few points. By extending the logic, we can choose to move only the even-indexed points, creating a more complex shape change with the same base code. This flexibility allows for a variety of blobby outlines from a single script, merely by adjusting the number of points and the destination value of the B variable.
Another powerful variation uses a custom random() function. This function allows us to control the seed, ensuring we get a consistent random sequence each time the paint is called (which is crucial to avoid flicker with transitions or animations). With this function, we can move every point by a random distance between 0 and B, resulting in a more fluid, organic morphing effect.
To choose between the uniform and random configurations without rewriting JavaScript, we introduce a boolean-like variable (T) that acts as a switch to enable the appropriate code path.
This architecture is quite modular. We can control the shape's complexity with N, the scale of the animation with the V variable, the type of movement with T, and the randomness seed to get unique shapes. Furthermore, this setup integrates seamlessly with CSS animations and keyframes with custom easing curves for more polished effects.
Controlling Point Movement for Diverse Effects
To unlock even more animations, we can control the x and y coordinates of the points independently. Instead of moving points linearly towards the center, we can define separate functions Fx(B) and Fy(B) for each axis.
One-Axis and Directional Movement
If we set one of these functions to zero, the points move only along a single axis. For example, making Fy(B) zero confines the movement to the horizontal plane, generating a squishing or stretching effect.
We can also make points move in the same direction (e.g., left or right) rather than converging. This requires a conditional offset based on the point's location relative to the center. We can divide the points into two groups—those on the left side (angles in the [90deg 270deg] range or indices [0.25N to 0.75N]) and those on the right—and apply a different sign to their movement value.
This creates an offset effect, but introduces a key issue: some points move away from the center, potentially going outside the mask area. To correct this, the initial shape must be reduced in size. By decreasing the base radius by the maximum allowed distance (V), we ensure that the blob's outline stays within the element's bounds during the animation.
This reduction creates a small problem: the hover-able area is larger than the visual shape. This can be solved with an extra wrapper element. By making the wrapper inline-block and using negative margins on the image equal to V, we shrink its interactive box to match the visual size. We then disable pointer-events on the image to ensure only the wrapper triggers the state change.
Orbital and Spiral Motion
We can also create continuous, infinite animations by making points orbit a fixed position rather than moving between two points. Instead of moving toward the center, each point revolves around its initial location along a circular path with a small radius r. The value of B controls the progression along this orbit from 0 to 1 (a full turn).
To prevent overlapping paths between adjacent points, the orbit's radius has a maximum value, which we calculate to ensure the orbits fit. This complex math is easily handled within a JavaScript code block, allowing a transition on B from 0 to N to produce a smooth, swirling rotation of the points.
For a final layer of complexity, we can combine the orb~ital animation with the original direct movement. By introducing a second transition variable (e.g., Bo), we can animate both the orbital position (B) and the orbit itself moving in from the edge or elsewhere. This creates a fascinating "spiral" effect, where the control points retract while they revolve.
A Modular Framework for Complex Masks
The flexibility we've seen comes from a very clean and modular code structure. We can categorize all the possible animations and their controls as variables:
- The number of points (
N): Determines the complexity and smoothness of the shape's edges. - The type of movement (
T): Acts as a boolean switch to determine if points move uniformly or randomly. - The random seed variable: Used by our custom
random()function to ensure a consistent sequence for each frame and allow for creating variations in the final shape. - The nature of movement: The geometric path the points take (moving from edge to center, along an axis, orbital, etc.). This is where the main conditional logic branches occur.
- The animation variable (
B): The CSS variable with atransitionor@keyframescontrolled by CSS. The code uses its value to calculate positions along the path defined by the "nature of movement." - The shape area: The base shape size is often reduced by the animation's boundaries (e.g.,
V) to prevent points from moving outside the designated box without clipping.
The core algorithm is surprisingly minimal: two main functions (Fx and Fy) handle the x and y coordinates based on the chosen "nature" and type, while a separate function determines the scaling factor to adjust the base shape's size. The CSS then simply defines the variables (N, T, etc.), applies the paint as a mask, and animates the B variable. This foundation makes it trivial to combine the various effects together to create extremely versatile, content-rich animations.
Putting the Parts Together
The early segments of this series dealt with the HTML specificities when using the Paint API. Now we can focus on the actual drawing — the part that connects the design idea with the code that renders it.
All drawing happens inside the paint() method, which receives three arguments: the drawing context, the size of the element, and the properties we define. The context works just like a standard Canvas 2D context, save for a few differences. You don't need to worry about coordinate systems or scaling factors. The API already handles those for you — by default, the coordinates match the CSS pixel space of the element being painted.
Checking the Environment
Before we can use a custom paint worklet, we need to confirm the browser supports it. A simple feature check can be done in JavaScript:
if ('paintWorklet' in CSS) {
// proceed with confidence
} else {
// fall back to another approach
}
If the browser supports it, we can load the module:
await CSS.paintWorklet.addModule('blob.js');
Inside the worklet file itself, we verify the context we're in. A paint worklet doesn't have access to the window object, so a quick check helps us set up our dependencies:
if (typeof window !== 'undefined') {
// we're in a regular script
} else {
// we're in the worklet, register the paint
}
Determining Which Element to Paint
A worklet can paint many different elements. Before painting, we want to read the CSS properties that are specific to the current element. The properties argument passed to the paint() method contains the computed values for all the CSS custom properties we've registered for this worklet.
const backgroundProp = properties.get('--bg-color');
// The property value is a CSSStyleValue.
// Convert to a plain string, or use it directly.
For an unregistered property that might not exist, we wrap the read in a check. This way, we use fallback values when necessary, which keeps the component robust.
Including CSS Custom Properties
One of the most useful patterns is registering input properties with registerProperty(). This gives the custom property a type and, importantly, allows the browser to animate it. Regular custom properties are always treated as strings and are not interpolable.
If we want smooth animation of our blob's shape, every dynamic variable of the shape must be a registered property with a number or color type.
CSS.registerProperty({
name: '--blob-size',
syntax: '<number>',
inherits: false,
initialValue: '1'
});
Here, the syntax defines what type we expect. The initialValue gives it a starting point, and inherits controls whether the property is passed down to child elements. When all dynamic properties are registered this way, CSS transitions and keyframes can be applied to them, and subsequently to the paint() method, producing the animated blob effect.
Shifting the Animation Style
From a design perspective, choosing the animation's dynamic points is key. A blob-like, morphing transition works well when points move along different axes with varied frequencies and offsets. You set up keyframes for registered custom variables and apply them to the element that gets painted:
.element {
animation: blobAnimation 3s infinite alternate;
}
Rather than animating CSS properties directly, we set up keyframes to update the numbers that control the shape — those few x and y multipliers we register as custom properties. The paint() method picks up these continuously updated numbers on each frame, redrawing the shape and pushing the motion forward.
From here, you can create more elaborate, playful loops by adjusting the keyframe timestamps and animating for longer periods, making the shape appear nearly independent in movement.
Browser Compatibility and Design Implications
Chrome, Edge, and Opera support the CSS Paint API out of the box. Firefox has it strictly behind a flag. Safari shows very limited early support. Given this, paint worklets remain an enhancement — not a baseline technique. All content and functionality should work without the worklet loaded.
In practice, this means to put a visual effect upfront but make certain the element has a sensible fallback style. Sizing and overflow thresholds should reflect the effect as being absent. No information should be presented solely via paint() context, since a user without support would simply get an empty box. The core CSS properties of the element, such as its position and existing content, should remain effective irrespective of the worklet's execution. So, test your paint effects with the worklet disabled and see if the thing still looks coherent.



