How To Make Dynamic Backgrounds With The CSS Paint API

How To Make Dynamic Backgrounds With The CSS Paint API

Create resolution-independent, variable backgrounds on the fly

Ferenc Almasi • 🔄 2021 November 11 • 📖 10 min read

Modern web applications are heavy on images. They are responsible for most of the bytes that are downloaded. By optimizing them, you can better leverage their performance. If you happen to use geometric shapes as background images, there is an alternative. You can use the CSS Paint API to generate backgrounds programmatically.

In this tutorial, we will explore its capabilities and look at how we can use it to create resolution-independent, dynamic backgrounds on the fly. This will be the output of this tutorial:

The CSS Paint API in action
Looking to improve your skills? Check out our interactive course to master JavaScript from start to finish.
Master JavaScript

Setting Up the Project

Let’s start by creating a new index.html file and filling it up with the following:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>🎨 CSS Paint API</title>
        <link rel="stylesheet" href="styles.css" />
    </head>
    <body>
        <textarea class="pattern"></textarea>
        <script>
            if ('paintWorklet' in CSS) {
                CSS.paintWorklet.addModule('pattern.js');
            }
        </script>
    </body>
</html>
index.html
Copied to clipboard!

There are a couple of things to note:

  • On line 13, we load a new paint worklet. Global support currently sits at ~63%. Because of this, we must first check if paintWorklet is supported.
  • I’m using a textarea for demonstration purposes so we can see how resizing the canvas will redraw the pattern.
  • Lastly, you need to create a pattern.js — which will register the paint worklet — as well a styles.css where we can define a couple of styles.

What is a worklet?

A paint worklet is a class that defines what should be drawn onto your canvas. They work similarly to the canvas element. If you have previous knowledge of it, the code will look familiar. However, they are not 100% identical. For example, text-rendering methods are not yet supported in worklets.

While here, let’s also define the CSS styles. This is where we need to reference that we want to use a worklet:

.pattern {
    width: 250px;
    height: 250px;
    border: 1px solid #000;

    background-image: paint(pattern);
}
styles.css
Copied to clipboard!

I’ve added a black border so we can better see the textarea. To reference a paint worklet, you need to pass paint(worklet-name) as a value to a background-image property. But where did pattern come from? We haven’t defined it yet, so let’s make it our next step.


Defining the Worklet

Open up your pattern.js and add the following content to it:

class Pattern {
    paint(context, canvas, properties) {
        
    }
}

registerPaint('pattern', Pattern);
pattern.js
Copied to clipboard!

This is where you can register your paint worklet with the registerPaint method. You can reference the first parameter in your CSS that you defined here. The second parameter is the class that defines what should be painted on the canvas. This has a paint method that takes three parameters:

  • context: This returns a PaintRenderingContext2D object that implements a subset of the CanvasRenderingContext2D API
  • canvas: This is our canvas, a PaintSize object that only has two properties: width and height
  • properties: This returns a StylePropertyMapReadOnly object that we can use to read CSS properties and their values through JavaScript.

Drawing the rectangles

Our next step is to get something showing up, so let’s draw the rectangles. Add the following into your paint method:

paint(context, canvas, properties) {
    for (let x = 0; x < canvas.height / 20; x++) {
        for (let y = 0; y < canvas.width / 20; y++) {
            const bgColor = (x + y) % 2 === 0 ? '#FFF' : '#FFCC00';
  
            context.shadowColor = '#212121';
            context.shadowBlur = 10;
            context.shadowOffsetX = 10;
            context.shadowOffsetY = 1;
  
            context.beginPath();
            context.fillStyle = bgColor;
            context.rect(x * 20, y * 20, 20, 20);
            context.fill();
        }
    }
}
pattern.js
Copied to clipboard!

All we’re doing here is creating a nested loop for looping through the width and height of the canvas. Since the size of the rectangle is 20, we want to divide both its height and width by 20.

On line four, we can switch between two colors using the modulus operator. I’ve also added some drop shadows for depth. And finally, we draw the rectangles on the canvas. If you open this in your browser, you should have the following:

Creating the pattern with CSS Paint API
Looking to improve your skills? Check out our interactive course to master JavaScript from start to finish.
Master JavaScript

Making the Background Dynamic

Unfortunately, apart from resizing the textarea and getting a glimpse of how the Paint API redraws everything, this is mostly still static. So let’s make things more dynamic by adding custom CSS properties that we can change.

Open your styles.css and add the following lines to it:

.pattern {
    width: 250px;
    height: 250px;
    border: 1px solid #000;

    background-image: paint(pattern);
    --pattern-color: #FFCC00;
    --pattern-size: 23;
    --pattern-spacing: 0;
    --pattern-shadow-blur: 10;
    --pattern-shadow-x: 10;
    --pattern-shadow-y: 1;
}
styles.css
Copied to clipboard!

You can define custom CSS properties by prefixing them with --. These can then be used by the var() function. But in our case, we will use it in our paint worklet.

Checking support in CSS

To make sure that the Paint API is supported, we can also check for support in CSS. To do this, we have two options:

  • Guarding the rules with the @supports rule
  • Using a fallback background image
/* 1st option */
@supports (background: paint(pattern)) {
  /**
   * If this part gets evaluated, it means
   * that the Paint API is supported
   **/
}

/**
 * 2nd option
 * If the Paint API is supported, the latter rule will override
 * the first one. If it's not, CSS will invalidate it, and the one
 * with url() will be applied
 **/
.pattern {
  background-image: url(pattern.png);
  background-image: paint(pattern);
}
styles.css
Copied to clipboard!

Accessing the parameters in the paint worklet

To read these parameters inside pattern.js, you need to add a new method to the class that defines the paint worklet:

class Pattern {
    // Anything that the `inputProperties` method returns
    // the paint worklet will have access to
    static get inputProperties() { 
        return [
            '--pattern-color',
            '--pattern-size',
            '--pattern-spacing',
            '--pattern-shadow-blur',
            '--pattern-shadow-x',
            '--pattern-shadow-y'
        ]; 
    }
}
pattern.js
Copied to clipboard!

To access these properties inside the paint method, you can use properties.get:

paint(context, canvas, properties) {
    const props = {
        color: properties.get('--pattern-color').toString().trim(),
        size: parseInt(properties.get('--pattern-size').toString()),
        spacing: parseInt(properties.get('--pattern-spacing').toString()),
        shadow: {
            blur: parseInt(properties.get('--pattern-shadow-blur').toString()),
            x: parseInt(properties.get('--pattern-shadow-x').toString()),
            y: parseInt(properties.get('--pattern-shadow-y').toString())
        }
    };
}
pattern.js
Copied to clipboard!

For the color, we need to convert it into a string. Everything else will need to be converted into a number. This is because properties.get returns a CSSUnparsedValue.

the return value of properties.get
The return value of properties.get.

To make things a little bit more readable, I’ve created two new functions that will handle the parsing for us:

paint(context, canvas, properties) {
    const getPropertyAsString = property => properties.get(property).toString().trim();
    const getPropertyAsNumber = property => parseInt(properties.get(property).toString());

    const props = {
        color: getPropertyAsString('--pattern-color'),
        size: getPropertyAsNumber('--pattern-size'),
        spacing: getPropertyAsNumber('--pattern-spacing'),
        shadow: {
            blur: getPropertyAsNumber('--pattern-shadow-blur'),
            x: getPropertyAsNumber('--pattern-shadow-x'),
            y: getPropertyAsNumber('--pattern-shadow-y')
        }
    };
}
pattern.js
Copied to clipboard!

All we need to do now is replace everything in the for loop with the corresponding prop values:

for (let x = 0; x < canvas.height / props.size; x++) {
    for (let y = 0; y < canvas.width / props.size; y++) {
        const bgColor = (x + y) % 2 === 0 ? '#FFF' : props.color;

        context.shadowColor = '#212121';
        context.shadowBlur = props.shadow.blur;
        context.shadowOffsetX = props.shadow.x;
        context.shadowOffsetY = props.shadow.y;

        context.beginPath();
        context.fillStyle = bgColor;
        context.rect(x * (props.size + props.spacing),
                     y * (props.size + props.spacing), props.size, props.size);
        context.fill();
    }
}
pattern.js
Copied to clipboard!

Now go back to your browser and try to change things around.

Editing the background generated by the CSS Paint API
Editing the background inside DevTools.

Summary

So why CSS Paint API might be useful for us? What are the use cases? The most obvious one is that it reduces the size of your responses. By eliminating the use of images, you save one network request and a handful of kilobytes. This improves performance.

For complex CSS effects that use DOM elements, you also reduce the number of nodes on your page. Since you can create complex animations with the Paint API, there’s no need for additional empty nodes.

In my opinion, the biggest benefit is that it's far more customizable than static background images. The API also creates resolution-independent images, so you don’t need to worry about missing out on a single screen size.

If you choose to use the CSS Paint API today, make sure you provide polyfill, as it is still not widely adopted. If you would like to tweak the finished project, you can clone it from this GitHub repository. Thank you for taking the time to read this article. Happy coding! 🎨

Simple Ways to Fake Masonry in CSS
Share on
  • twitter
  • facebook
JavaScript Course Dashboard

Tired of looking for tutorials?

You are not alone. Webtips has more than 400 tutorials which would take roughly 75 hours to read.

Check out our interactive course to master JavaScript in less time.

Learn More

Recommended