A New Canvas: Drawing Procedural Patterns with the CSS Paint API
For years, creating complex or generative visuals in the browser meant wrestling with CSS or JavaScript libraries. CSS itself never had a native API dedicated purely to drawing. That has changed with the introduction of the Houdini APIs, specifically the CSS Paint API. This specification gives developers low-level access to CSS rendering, allowing you to programmatically generate images wherever CSS expects one, such as a background-image. It opens the door to creating dynamic, unique visuals that are fast, responsive, and integrate cleanly with your design systems.
This tutorial will guide you through building three distinct generative patterns using the Paint API. We'll cover the foundational concepts and then work through the code for each pattern, starting with a basic setup that you can adapt for your own projects. All demos currently work in Chrome and Edge, so be sure to test in those browsers.
Understanding the Core Concepts
Before diving into the code, it's helpful to briefly understand the two main ideas we'll be working with: generative art and the Paint API worklet.
What is Generative Art?
At its core, generative art is any work created with a degree of chance. You set the rules, and a source of randomness determines the final outcome. For example, a simple rule might be: "If a random number is greater than 50, draw a red square; otherwise, draw a blue square." In the browser, this randomness typically comes from a source like Math.random(). This approach allows you to generate near-infinite variations of a single concept, offering a way to show every visitor a unique piece of imagery.
How the CSS Paint API Works
The Paint API works through "paint worklets"—JavaScript classes with a special paint() function. This function uses a 2D drawing context that is almost identical to the HTML canvas API. The key is that the worklet's logic runs on a separate browser thread, making it efficient and responsive. Here’s a basic example of a worklet being applied:
.worklet-canvas {
background-image: paint(workletName);
}
One critical aspect of building robust worklets is ensuring they are deterministic. This means that given the same input (like element dimensions and custom properties), the paint() function should always produce the same visual output. This is essential for two main reasons:
- The browser often caches a worklet's output for performance, which is impossible if the output is unpredictable.
- The
paint()function re-runs whenever the target element changes size. If it uses unpredictable randomness (likeMath.random()), users will see flashing content, which can be an accessibility issue.
To achieve deterministic randomness, we use a pseudo-random number generator. This is a function that produces a sequence of random-looking numbers based on an initial "seed" value. As long as the seed is the same, the sequence is identical. For our patterns, this means we can re-seed the generator with the same seed on every paint() call, ensuring a stable and consistent image between renders.
In our starter pen, we define a simple worklet class:
class Worklet {
paint(ctx, geometry, props) {
const { width, height } = geometry;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, width, height);
}
}
The paint() function automatically receives several parameters. We are interested in the first three:
ctx— A 2D drawing context similar to HTML canvas.geometry— An object with thewidthandheightof the target element.props— Input properties we can watch for changes. These are a way to pass CSS custom properties into our worklet.
After defining the class, we must register it before it can be used. This is done in two steps. First, we call registerPaint inside the worklet file itself:
if (typeof registerPaint !== "undefined") {
registerPaint("workletName", Worklet);
}
Second, we load the worklet file from our main thread using CSS.paintWorklet.addModule():
<script id="register-worklet">
if (CSS.paintWorklet) {
CSS.paintWorklet.addModule('https://codepen.io/georgedoescode/pen/bGrMXxm.js');
}
</script>
Finally, we can apply our worklet to any CSS property that expects an image. In this tutorial, we'll use background-image:
.worklet-canvas {
background-image: paint(workletName);
}
We will also use a static, seeded value for our randomness. The example below shows a "random circles" worklet that correctly re-renders its pattern without any visual flashing on resize.
| Android Chrome | Android Firefox | Android | iOS Safari |
|---|---|---|---|
| 151 | No | 151 | No |
Setting Up Each Pattern
Before beginning each pattern, you should fork the "starter Pen." This creates a copy for you to work with. There is a critical step to complete: The URL passed to CSS.paintWorklet.addModule must be updated to point to your new fork's JavaScript file. To find this, take your fork's URL (with all query parameters removed) and append .js to the end.

Make sure to use this new URL in your module loading code. When working with CodePen, you may need to manually refresh the preview to see your changes. You can do this with CMD/CTRL + Shift + 7.
Pattern #1: "Tiny Specks"
Let's begin with our first pattern, a chaotic arrangement of small, rotated ellipses. To start, fork the starter Pen and update the JavaScript file reference as described above.
Updating the Worklet's Name
First, we'll update the worklet's class name and its references.
class TinySpecksPattern {
// ...
}
if (typeof registerPaint !== "undefined") {
registerPaint("tinySpecksPattern", TinySpecksPattern);
}
.worklet-canvas {
/* ... */
background-image: paint(tinySpecksPattern);
}
Defining Input Properties
Our "Tiny Specks" worklet accepts the following input properties. These are passed as CSS custom properties.
--pattern-seed— A seed value for the pseudo-random number generator.--pattern-colors— The available colors for each speck.--pattern-speck-count— The number of specks to render.--pattern-speck-min-size— The minimum size for each speck.--pattern-speck-max-size— The maximum size for each speck.
We define these in our worklet class using a getter for inputProperties.
class TinySpecksPattern {
static get inputProperties() {
return [
"--pattern-seed",
"--pattern-colors",
"--pattern-speck-count",
"--pattern-speck-min-size",
"--pattern-speck-max-size"
];
}
// ...
}
We also define them in our CSS with sensible defaults and specific syntax using the Properties and Values API. Defining the syntax allows the worklet to receive complex values like a color array instead of just a plain string.
@property --pattern-seed {
syntax: "<number>";
initial-value: 1000;
inherits: true;
}
@property --pattern-colors {
syntax: "<color>#";
initial-value: #161511, #dd6d45, #f2f2f2;
inherits: true;
}
@property --pattern-speck-count {
syntax: "<number>";
initial-value: 3000;
inherits: true;
}
@property --pattern-speck-min-size {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
@property --pattern-speck-max-size {
syntax: "<number>";
initial-value: 3;
inherits: true;
}
The Properties and Values API, another member of the Houdini family, lets us assign a
syntaxdefinition to our custom properties. Here,<color>#tells the browser that--pattern-colorsis a list of colors, which the worklet then receives as an array of parsed RGB values.
The paint() Function
Now for the core logic. First, we clear out the starter paint() function, keeping the width and height definitions.
paint(ctx, geometry, props) {
const { width, height } = geometry;
}
Next, we store our input properties in variables.
const seed = props.get("--pattern-seed").value;
const colors = props.getAll("--pattern-colors").map((c) => c.toString());
const count = props.get("--pattern-speck-count").value;
const minSize = props.get("--pattern-speck-min-size").value;
const maxSize = props.get("--pattern-speck-max-size").value;
Then, we initialize our pseudo-random number generator using the seed value.
random.use(seedrandom(seed));
Finally, we create a loop to render each speck. For every iteration, we define a position, size, and color.
for (let i = 0; i < count; i++) {
}
const x = random.float(0, width);
const y = random.float(0, height);
const radius = random.float(minSize, maxSize);
ctx.fillStyle = colors[random.int(0, colors.length - 1)];
To draw each speck, we need to apply rotation. Since we cannot rotate a single object, we save the context's state, translate and rotate the entire drawing space, then restore it to avoid stacking transformations.
ctx.save();
ctx.translate(x, y);
ctx.rotate(((random.float(0, 360) * 180) / Math.PI) * 2);
ctx.translate(-x, -y);
Now we can render the ellipse and finish by restoring the context.
ctx.beginPath();
ctx.ellipse(x, y, radius, radius / 2, 0, Math.PI * 2, 0);
ctx.fill();
ctx.restore();
To finish off the effect, we apply a background color to the canvas.
.worklet-canvas {
background-color: #90c3a5;
background-image: paint(tinySpecksPattern);
}
That's it! Our first pattern is complete. From here, you can try modifying the colors, shapes, or distribution of the specks. For a quick challenge, see if you can render small triangles or lines instead of ellipses.
Pattern #2: "Bauhaus"
Next, we’ll create a pattern with a strict, grid-based layout. Fork the starter Pen to get started. This pattern defines a fixed square "cell" of a pattern and then scales it to cover the target element.
Updating the Worklet's Name
As before, we first update the worklet's class name and its references.
class BauhausPattern {
// ...
}
if (typeof registerPaint !== "undefined") {
registerPaint("bauhausPattern", BauhausPattern);
}
.worklet-canvas {
/* ... */
background-image: paint(bauhausPattern);
}
Defining Input Properties
Our "Bauhaus Pattern" worklet expects four input properties:
--pattern-seed— A seed value for the pseudo-random number generator.--pattern-colors— The available colors for each shape in the pattern.--pattern-size— A value used to define the width and height of a square pattern area.--pattern-detail— The number of columns and rows to divide the square pattern into.
We add these to our worklet's inputProperties.
class BahausPattern {
static get inputProperties() {
return [
"--pattern-seed",
"--pattern-colors",
"--pattern-size",
"--pattern-detail"
];
}
// ...
}
...and define them in our CSS with default values and syntax.
@property --pattern-seed {
syntax: "<number>";
initial-value: 1000;
inherits: true;
}
@property --pattern-colors {
syntax: "<color>#";
initial-value: #2d58b5, #f43914, #f9c50e, #ffecdc;
inherits: true;
}
@property --pattern-size {
syntax: "<number>";
initial-value: 1024;
inherits: true;
}
@property --pattern-detail {
syntax: "<number>";
initial-value: 12;
inherits: true;
}
The paint() Function
After clearing out the starter function, we store our input properties, seed the random generator, and then implement the scaling behavior.
paint(ctx, geometry, props) {
const { width, height } = geometry;
}
const patternSize = props.get("--pattern-size").value;
const patternDetail = props.get("--pattern-detail").value;
const seed = props.get("--pattern-seed").value;
const colors = props.getAll("--pattern-colors").map((c) => c.toString());
random.use(seedrandom(seed));
We'll create a fixed-dimension square. To make it cover the entire target element, we can add a scaleContext function that automatically scales the drawing context.
scaleCtx(ctx, width, height, elementWidth, elementHeight) {
const ratio = Math.max(elementWidth / width, elementHeight / height);
const centerShiftX = (elementWidth - width * ratio) / 2;
const centerShiftY = (elementHeight - height * ratio) / 2;
ctx.setTransform(ratio, 0, 0, ratio, centerShiftX, centerShiftY);
}
Call it first thing in paint() so we can work within a set of fixed dimensions and let the drawing context handle all the resizing.
this.scaleCtx(ctx, patternSize, patternSize, width, height);
Now, we build a 2D grid of cells.
const cellSize = patternSize / patternDetail;
for (let x = 0; x < patternSize; x += cellSize) {
for (let y = 0; y < patternSize; y += cellSize) {
}
}
Within our nested loops, we choose a random color and determine the current cell's center position.
const color = colors[random.int(0, colors.length - 1)];
ctx.fillStyle = color;
const cx = x + cellSize / 2;
const cy = y + cellSize / 2;
To help us, we'll define some utility functions for drawing shapes relative to their center.
function circle(ctx, cx, cy, radius) {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.closePath();
}
function arc(ctx, cx, cy, radius) {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 1);
ctx.closePath();
}
function rectangle(ctx, cx, cy, size) {
ctx.beginPath();
ctx.rect(cx - size / 2, cy - size / 2, size, size);
ctx.closePath();
}
function triangle(ctx, cx, cy, size) {
const originX = cx - size / 2;
const originY = cy - size / 2;
ctx.beginPath();
ctx.moveTo(originX, originY);
ctx.lineTo(originX + size, originY + size);
ctx.lineTo(originX, originY + size);
ctx.closePath();
}
With those in place, we can pick a random shape and a random rotation for each of our grid cells.
const shapeChoice = ["circle", "arc", "rectangle", "triangle"][
random.int(0, 3)
];
const rotationDegrees = [0, 90, 180][random.int(0, 2)];
Next, we save the context, translate and rotate, then render the chosen shape using a switch statement.
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((rotationDegrees * Math.PI) / 180);
ctx.translate(-cx, -cy);
switch (shapeChoice) {
case "circle":
circle(ctx, cx, cy, cellSize / 2);
break;
case "arc":
arc(ctx, cx, cy, cellSize / 2);
break;
case "rectangle":
rectangle(ctx, cx, cy, cellSize);
break;
case "triangle":
triangle(ctx, cx, cy, cellSize);
break;
}
ctx.fill();
Finally, we restore the drawing context ready for the next shape.
ctx.restore();
With that, our Bauhaus Grids worklet is finished. For next steps, consider how to parameterize it further. Can you add a bias for specific shapes or colors? Add new shape types to the library?
Pattern #3: "Voronoi Arcs"
For our final example, we’ll create a pattern that feels organic and organic, and is responsive in a more unusual way. This pattern uses a Voronoi tessellation to calculate its layout. This is essentially a way to partition a space into a set of non-overlapping polygons based on a collection of points.
The fascinating part of this layout is its inherent responsiveness. As the target element is resized, the points within the tessellation move automatically, causing the dividing polygons to re-arrange themselves to perfectly fill the available space.
Fork the starter Pen and update the worklet names accordingly.
class VoronoiPattern {
// ...
}
if (typeof registerPaint !== "undefined") {
registerPaint("voronoiPattern", VoronoiPattern);
}
.worklet-canvas {
/* ... */
background-image: paint(voronoiPattern);
}
Defining Input Properties
The VoronoiPattern worklet expects three input properties:
--pattern-seed— A seed value for the pseudo-random number generator.--pattern-colors— The available colors for each arc/circle in the pattern.--pattern-background— The pattern's background color.
These are added to our worklet's inputProperties:
class VoronoiPattern {
static get inputProperties() {
return ["--pattern-seed", "--pattern-colors", "--pattern-background"];
}
// ...
}
...and defined in our CSS:
@property --pattern-seed {
syntax: "<number>";
initial-value: 123456;
inherits: true;
}
@property --pattern-background {
syntax: "<color>";
inherits: false;
initial-value: #141b3d;
}
@property --pattern-colors {
syntax: "<color>#";
initial-value: #e9edeb, #66aac6, #e63890;
inherits: true;
}
The paint() Function
First, clear out the starter function. Then, as in previous examples, store our input properties and seed the random number generator. After that, paint a quick background color for the pattern.
paint(ctx, geometry, props) {
const { width, height } = geometry;
const seed = props.get("--pattern-seed").value;
const background = props.get("--pattern-background").toString();
const colors = props.getAll("--pattern-colors").map((c) => c.toString());
random.use(seedrandom(seed));
}
ctx.fillStyle = background;
ctx.fillRect(0, 0, width, height);
Now we'll use a helper function that simplifies creating a Voronoi tessellation. This function wraps a library like d3-delaunay.
import { createVoronoiTessellation } from "https://cdn.skypack.dev/@georgedoescode/generative-utils";
Add that function to the paint() method to create a tessellation across the full element size with 24 controlling points.
const { cells } = createVoronoiTessellation({
width,
height,
points: [...Array(24)].map(() => ({
x: random.float(0, width),
y: random.float(0, height)
}))
});
Now we can loop through each cell in the tessellation. For each, we choose a color, get its center, and save/restore the context as before.
cells.forEach((cell) => {
});
ctx.fillStyle = colors[random.int(0, colors.length - 1)];
const cx = cell.centroid.x;
const cy = cell.centroid.y;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((random.float(0, 360) / 180) * Math.PI);
ctx.translate(-cx, -cy);
For each cell, we can then draw an arc with an angle of either PI (a semi-circle) or PI * 2 (a full circle). The createVoronoiTessellation function provides an innerCircleRadius value on each cell, representing the largest circle that can fit in its center. We can use this to control the arc's size.
ctx.beginPath();
ctx.arc(
cell.centroid.x,
cell.centroid.y,
cell.innerCircleRadius * 0.75,
0,
Math.PI * random.int(1, 2)
);
ctx.fill();
To add depth, we draw another arc on some cells (25% of the time) using the worklet’s background color. This creates the visual effect of a hole within the shapes.
if (random.float(0, 1) > 0.25) {
ctx.fillStyle = background;
ctx.beginPath();
ctx.arc(
cell.centroid.x,
cell.centroid.y,
(cell.innerCircleRadius * 0.75) / 2,
0,
Math.PI * 2
);
ctx.fill();
}
Finally, we restore the drawing context.
ctx.restore();
That completes the final pattern. The power of the Voronoi layout is that you can render absolutely anything within each cell—from lines and triangles to more complex shapes.
Randomizing Patterns on Page Load
So far, we have passed a static --pattern-seed to patterns, resulting in the same layout every time. To create a unique pattern for each user, you can set this property with a random number on page load. This simple addition ensures every page visit is a slightly different experience.
document.documentElement.style.setProperty('--pattern-seed', Math.random() * 10000);


