Dec 17

CSS Houdini Paint API

CSS Houdini Paint API allows you to create your own custom 2D rendering that can be painted as an image in CSS.

By Stephanie Eckles

The CSS Houdini spec contains a few different APIs but the currently best-supported is the Paint API. Fortunately, there is a polyfill that allows the API to be used today, but it should still be considered a progressive enhancement.

Let's learn more about the Houdini Paint API and create our own worklet - the JavaScript class that contains our paint function.

What is CSS Houdini

Houdini APIs give you direct access to the CSS Object Model (CSSOM) which means you can provide custom instructions to the browser about how to render features not available in CSS. Since Houdini passes off your worklet function to the browser rendering engine, they are very performantly painted off the main thread.

Now, I could explain what's possible with Houdini, but I recommend skipping over to Houdini.how for a few minutes to scroll through the examples.

Notice that paint() function applied to things like background, background-image, border-image, and mask-image? That's how the paint worklet is called. Additionally, a worklet can receive CSS custom properties which makes them flexible and customizable per implementation.

Houdini worklets are encouraged to be shared, and you may have also noticed how those demos included a CDN link and/or NPM link. An additional reason beyond sharing the worklets is that they must be run on a local server or over HTTPS.

Developing a CSS Houdini Paint worklet

Worklets are created as JavaScript classes. For a preview of the worklet we're going to create, scroll to the bottom of the page! The "snow" effect is created via Houdini.

Worklet environment and initial setup

To create the worklet, I'll assume you're working on a local server. I prefer Eleventy to quickly get this up and running, but any setup that can run a local server such as through Browsersync will do.

For the simplest starting point, create:

  1. worklet.js
  2. index.html
  3. style.css

In your HTML file, we won't link to the worklet like a normal script. Instead, we'll call it with a special function to make it available to the browser and our styles:

<!-- added in index.html -->
<script>
if (CSS.paintWorklet) {
CSS.paintWorklet.addModule("worklet.js");
}
</script>

Earlier I mentioned there was a polyfill, which you'll need for support currently in Firefox and Safari. As the simplest way to include that, you can link to it prior to including your worklet from the unpkg CDN:

<script src="https://unpkg.com/css-paint-polyfill"></script>

While in your HTML file, let's add a simple div that we'll attach the worklet to for testing. There seem to be some issues with trying to attach a worklet to the body unless you will not pass it custom properties. This element can certainly contain other content, it does not need to be reserved to work as a worklet target.

<div class="snow"></div>

Then we'll add just a touch of style to our document so that our snow container has dimensions. We'll also set a background color because that will not be provided as part of our worklet.

* {
box-sizing: border-box;
}

body {
margin: 0;
}

.snow {
background-color: lightblue;
min-height: 100vh;
}

Initialize the worklet class

Now in our worklet file, we'll begin by defining what we want to call our worklet, which we'll also use to prefix the related custom properties.

// worklet.js
const WORKLET = "houdini-snow";

Following that, we'll begin our class and include the initial call to paint:

class HoudiniSnow {
paint(ctx, size, properties) {

}
}

registerPaint(WORKLET, HoudiniSnow);

The class name is up to you, but notice it is also passed into the registerPaint function.

Select and access worklet custom properties

At this point, it's good to consider what custom properties - if any - we'd like to provide for customization. For this worklet, I decided on the following:

In order to access those properties, we'll use the method for inputProperties(). Of note, this method can access all properties on the element, not just custom ones. Place this prior to the paint() function within the class:

static get inputProperties() {
return [
`--${WORKLET}-min`,
`--${WORKLET}-max`,
`--${WORKLET}-fill`,
`--${WORKLET}-flakes`,
];
}

Now we're ready to begin painting, so let's examine the properties we've setup:

We'll begin by pulling out the width and height from size and also gathering the values from the custom properties. For those, we need to do a bit of extra processing depending on what type we're expecting - integer or string. And, we set some defaults in case the properties are not set.

paint(ctx, size, properties) {
const { width, height } = size;
const min = parseInt(properties.get(`--${WORKLET}-min`)) || 2;
const max = parseInt(properties.get(`--${WORKLET}-max`)) || 7;
// fill: "all" | "top" | "bottom"
const fill = properties.get(`--${WORKLET}-fill`).toString().trim() || "all";
const flakes = parseInt(properties.get(`--${WORKLET}-flakes`)) || 1000;
}

Note that for the fill we'll accept three possible strings: all to cover the element, or either top or bottom to only cover the chosen half.

Add randomness responsibly with a PRNG

In order to place the snowflakes, which we'll simply draw as circles, we need to create an array populated with x and y coordinates which is what the canvas requires. And to ensure they don't all end up in the same place but are distributed across the available space, we need a way to introduce some randomness.

The key to remember here about the paint API is that repainting is triggered when anything causes the layout to change. For example, resizing the browser. Depending on your worklet, allowing randomized positioning on repaint can cause an unwanted motion effect or even flashing from the rapid repaints. And that can cause some users migraines or even seizures.

Clever mathematical-minded folks have figured out how to create what they call pseudorandom number generators (PRNG). These have the appearance of randomness, but the behavior is that given an input the function always returns the same output. I found out about this method from George Francis, and I highly recommend their resources if you are interested in diving deep with the paint API and particularly generative art.

The PRNG that George recommends is mulberry32, which looks quite scary but what it ultimately does is intake a "seed" number and returns a number between 0 and 1 for each request. So while those individual requests return a different number, as a group the returned numbers are the same so the output ultimately has the appearance of being the same.

This function may be placed outside of the class.

function mulberry32(a) {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

We will use the number of flakes as the seed, and create the random variable within our paint function:

const random = mulberry32(flakes);

Create an array of snowflake coordinates

So now we have a way to attach randomness, but we also need to work out how to select a value between a minimum and maximum. This will be used to select x and y points but also to size the snowflake circles.

Once again, George helps point us to the answer here which is a lerp (linear interpolation) function. Given a start, end, and amount value (a number between 0 and 1 aka our random number), it will return a point between the start and end.

We'll set up our lerp function next to the mulberry32 one. When we want to fill the lower half, we have to make an adjustment to ensure it picks a point within the higher range, and it was necessary to pass a fourth optional parameter.

function lerp(start, end, amt, fill) {
if (fill === "bottom") {
return end - (start * amt);
} else {
return (end - start) * amt;
}
}

The easy coordinate is x which will always be plotted between 0 and the width of the element. Given this idea of "top" and "bottom" half, the coordinate this affects is y which will move the snowflake up or down within the element. So before we finally calculate the coordinates, we'll prepare variables to adjust the y range start and end.

const startPos = fill === "bottom" ? height / 2 : 0;
const endPos = fill === "top" ? heignt / 2 : height;

Now we have all the setup to create an array to hold each snowflakes coordinates:

const snowflakeArr = [...Array(flakes)].map(() => {
return {
x: lerp(0, width, random()),
y: lerp(startPos, endPos, random(), fill),
};
});

Draw the snowflakes within the rendering context

If you've made it this far, you're about to be rewarded with finally painting the snowflakes!

Finally, it's time to tap into the rendering context via our ctx parameter and draw the snowflake circles. We'll loop through our snowflakeArr and for each snowflake do the following:

  1. Generate the fill color using hsla to produce a white color that has a randomized alpha with the help of another call to lerp and the PRNG
  2. Begin drawing the path, a crucial step for making a shape
  3. Use the arc function to draw the circle, once again with the help of lerp and the PRNG to add variance to the snowflake circle size
  4. Add the fill color so that the snowflake circle is visible
snowflakeArr.forEach((point) => {
ctx.fillStyle = `hsla(0 0% 100% / ${lerp(0.4, 1, random())})`;
ctx.beginPath();
ctx.arc(point.x, point.y, lerp(min, max, random()), 0, Math.PI * 2);
ctx.fill();
});

And with that, our houdini-snow worklet is complete.

Demo of the houdini-snow worklet

Try out changing the provided custom properties for this demo in dev tools to see how they affect the rendering of our houdini-snow worklet.

houdini-snow worklet sandbox
.houdini-snow {
--houdini-snow-min: 4;
--houdini-snow-max: 8;
--houdini-snow-fill: top;
--houdini-snow-flakes: 400;

background-color: lightblue;
height: 30vh;
background-image: paint(houdini-snow);
}

Using the polyfill and providing fallback styles

While we already included the link to the polyfill earlier, there's an issue that can arise where it needs a little extra nudge to work after the page loads. We can use setTimeout to trigger a repaint on elements that should be rendering a worklet by calling window.getComputedStyle() against each element.

setTimeout(() => {
const snow = document.querySelector(".snow");

if (snow) {
window.getComputedStyle(snow);
}
}, 250);

As with anything, be sure to test in Firefox and Safari to ensure the rendering meets your expectations.

The polyfill does not seem to support rendering within pseudo-elements which can be a significant limitation if you are making your worklet a focal point. You may need to fall back to background images or other solutions, which you can accomplish using CSS @supports.

@supports not (background-image: paint(worklet)) {
/* fall back styles here */
}

Additional resources and worklet ideas

For an overview of additional features available in the Paint API, review the guide from MDN.

I've mentioned George Francis and if you are interested in the Houdini Paint API and/or generative art, definitely check out their other resources, including:

Over on CSS-Tricks, Temani Afif has done a series of ideas, starting with this one on image fragmentation effects (motion warning).

Jumpstart your worklet environment including getting it ready to publish on npm by using my Eleventy-based worklet starter.

Don't forget to check out the resources available on Houdini.how.

Have fun painting!