Programmatically create images with the CSS Paint API

By on August 27, 2018 9:00 am

illustration of a paint roller

The CSS Paint API is a modern web platform feature to programmatically create images in JavaScript which are rendered to the page when referenced by CSS. You create images using the Canvas API, an API with which you may already be familiar.

You can reference image URLs in CSS. For example in the background-image CSS property, you may write code like this:

.logo {
    background-image: url('logo.png');
}

Which typically follows this flow:

Flow Before CSS Paint

Now, with the CSS Paint API, you can do:

.logo {
    // fallback image
background-image: url('logo.png');    

// CSS Paint API
background-image: paint(logo);
}

Which follows this flow:

Flow With CSS Paint

You can use the CSS paint() function anywhere that you would expect a CSS image.

The CSS Paint API as a specification

The paint function you see in the latter code example is part of CSS Paint API which is in turn part of the Houdini project — a collection of new browser APIs. Houdini currently has limited browser support, and the CSS Paint API is currently available in Chrome and Chromium-based browsers.

Worklets

This JavaScript code you write which programmatically creates images gets referred to as a Paint Worklet. A Paint Worklet has some critical constraints, such as:

  • No network access
  • No storage access
  • Script lifetime does not get guaranteed; it can get killed at any point
  • No timer functions available like requestAnimationFrame or setInterval

A Worklet is an extension point into the browser rendering pipeline. Web Specifications define additional Worklets besides Paint Worklets. These include:

Audio Worklets — for dealing for Web Audio
Animation Worklets — can be used to create animations
Layout Worklets — handling of element geometry

All of these Worklets inherit from the parent Worklet specification; a new type of standard extension point which some new browser APIs use under the hood. A quick skim through the specification document highlights an important concept: Worklets are fairly independent to your usual main thread JavaScript, and they operate in a similar way to Web Workers.

The specification grants user agents some flexibility into how a Worklet executes and the order in which Worklets get executed. Because of these facts, and many others, it’s recommended that developer-defined Worklets are idempotent. Consider the limitations we covered earlier and notice how restricting access to the network, for example, helps your class Worklet class achieve idempotency.

Your Paint Worklet can execute once or many times and is all based on whether the browser determines the affected element needs repainting. Examples of repaint triggers can include:

Changes in CSS properties and/or their values
Changes triggered by hover styles
JavaScript style changes
Window resizing

Why an element needs to get repainted is outside the scope of this post, but feel free to learn more about it in this 7-minute video: An Introduction to Browser Rendering.

Simple code example

Here’s a minimal amount of code for creating and utilizing a Paint Worklet:

Step 1: Write the HTML and CSS to use a background image on an element:

<div class="element">hello</div>
.element {
    background-image: paint(my-paint-worklet);
}

Step 2: Import your Paint Worklet file from your main HTML file:

<script>
CSS.paintWorklet.addModule('worklet.js');
</script>

Step 3: Implement the Paint Worklet using canvas semantics:

// worklet.js
class MyWorklet {
    paint(ctx) {
        ctx.fillStyle = 'green';
        ctx.fillRect(0, 0, 20, 20);
    }
}

Step 4: Register the Paint Worklet:

// Register the worklet using...
// ...the built-in registerPaint() function
registerPaint('my-paint-worklet', MyWorklet);

Study the four steps shown above and note the following:

  • The CSS Paint API exposes a paint() function in CSS allowing you to refer to registered Paint Worklets.
  • Your Paint Worklet module does not get imported through script tags, but rather through the CSS.paintWorklet.addModule() method. This use of the term modules is unrelated to ES Modules.
  • The ID you pass to the registerPaint function in the JavaScript Worklet, and the ID you pass to the paint function in CSS must match. IDs must be unique.
  • The registerPaint function in step 4 is only available in Paint Worklets and must be registered there.

Paint method signature

The paint method receives the canvas context as the first argument. If you wish to explore other arguments which the paint method receives, you can discover them by logging them to the console:

class MyWorklet {
    paint(...args) {
        console.log(args);
    }
}

This example uses rest parameters, part of ES2015 and newer JavaScript syntax, to collect all arguments into an array named args.

If you are following along with this simple code example, notice an array of three items gets logged to the Console Panel:

  • The canvas context, which you may be familiar with from canvas-related code you’ve worked on previously
  • Paint size, an object containing a width and height attribute which represents the dimensions of the element in question
  • As the third and final argument, notice an object of the type StylePropertyMapReadOnly. This map represents the input properties for the paint worklet which gets covered in the following section

Input properties

A handy way to explain the input properties feature of the Paint API is with a code example. Take note of the following Paint Worklet class definition.

class MyWorklet {
    static get inputProperties() {
        return ['--custom-property-1', '--custom-property-2'];
    }

    paint(ctx, geometry, inputProperties) {
        const customProp1 = inputProperties.get('--custom-property-1');
        const customProp2 = inputProperties.get('--custom-property-2');
        console.log(customProp1.toString(), customProp2.toString());
        ctx.fillStyle = 'green';
        ctx.fillRect(0, 0, 20, 20);
    }
}

Tip: You can read more about the static keyword on MDN – it’s part of part of ES2015 and is not specific to Worklets.

The static inputProperties method returns an array of properties you wish to make available to the worklet. The final part of this demonstration on input properties is to use CSS Custom Properties to initialize those variables to a value. In the CSS:

.container {
    background-image: paint(my-paint-worklet);
    --custom-property-1: 100;
    --custom-property-2: 'some string';
}

You can use JavaScript to manipulate CSS custom properties. For example, if you execute the following code from outside a paint worklet:

const container = document.querySelector('.container');
container.style.cssText = '--custom-property-1: 200';

Using this technique of initializing and updating CSS custom properties from outside the scope of a Paint Worklet, you can theme your Paint Worklet using familiar CSS syntax. Paint Worklets can also get reused across multiple areas of your web application with different theming rules applied based on different contexts.

Animation

There are no immediately apparent ways to animate background images via the CSS Paint API. If you recall, the limitations include a lack of timer APIs within a worklet such as setInterval or requestAnimationFrame. Browser repainting allows you to achieve the effect of animation.

The browser typically invokes the paint method of your worklet as and when it detects a repaint is required. If you change your CSS Custom Property Values which get declared in the static input properties method of your worklet, the browser is likely to determine a repaint is required.

Here’s a minimal code example for achieving animation through the CSS Paint API. In this example, you can start by applying a CSS Custom Property within CSS:

.container {
    background-image: paint(my-paint-worklet);
    width: 200px;
    height: 200px;
    --ball-y-pos: 45;
}

Moreover, a Paint Worklet definition as follows:

class MyWorklet {
    static get inputProperties() {
        return [
            '--ball-y-pos'
        ];
    }

    paint(ctx, {width, height}, inputProperties) {
        const ballYPosValue = inputProperties.get('--ball-y-pos');

        const ballYPos = parseInt(ballYPosValue.toString());

        ctx.fillStyle = 'green';
        ctx.beginPath();
        ctx.arc(100, ballYPos, 10, 0, 2 * Math.PI);
        ctx.fill();
    }
}

registerPaint('my-paint-worklet', MyWorklet);

Notice: A circle (ctx.arc()) gets drawn, but instead of a hardcoded value for the y position, a CSS Custom Property value gets used instead.

In the CSS code, the y position of the ball gets initialized to 45. The circle initially gets drawn at 45 pixels down the page. So far, nothing within the code is likely to trigger any repaints. To trigger a repaint, update the --ball-y-pos CSS Custom Property value.

Here’s the complete, main thread JavaScript to animate a ball moving down the page. The commented numbers in the code correspond to code explanations below the code snippet:

function init() {
    // 1
    const styleMap = document.querySelector('style').sheet.cssRules[0].styleMap;

    // 2
    let currentBallYPos = parseInt(styleMap.get('--ball-y-pos').toString());

    const container = document.querySelector('.container');

    function animate() {
        // 3
        container.style.setProperty('--ball-y-pos',  currentBallYPos++)

        if (currentBallYPos &gt; 200) {
            return;
        }

        // 4
        requestAnimationFrame(animate);
    }

    animate();
}

init();

Code explanations:

  1. Retrieve a map of existing CSS style rules; our goal is to find the initial ball position.
  2. Parse the initial value of the ball’s y position. Tip: In the future, you’ll be able to parse CSS property values with the CSS Typed Object Model.
  3. Increment the ball’s y position and update the CSS Custom Property.
  4. Invoke the animate function for each available animation frame.

Debugging support

Chrome supports DevTools debugging of a Paint Worklet, meaning you can add the debugger keyword within the paint method of your Paint Worklet and DevTools pause at that line in question. Console logging statements behave as you would expect.

This behavior gets a special mention because, in the past, debugging JavaScript in different threads would not always work as expected.

Browser Support

CSS Paint API support is currently available in Chrome and Chromium-based browsers. You can also check support programmatically through CSS and JavaScript.

CSS Support Detection

Recall that a reference to a Paint Worklet gets made in CSS code. You can use the CSS @supports rule to detect support for the CSS paint() function:

@supports (background-image: paint(my-paint-worklet)) {
    /* CSS Paint API is supported */
}

JavaScript Support Detection

In JavaScript, you can use the following boolean expression to check for support:

if (window.CSS &amp;&amp; 'paintWorklet' in CSS) {
    // CSS Paint API is supported
}

Browsers which do not understand the paint() function call in CSS effectively discard that line of CSS and do not display the generated image. To improve this experience for your users, you can specify a fallback image to be displayed:

background-image: url('fallback.png');
background-image: paint(my-paint-worklet);

By ensuring the generated images are complementary and are not required to understand the critical content of your site, you can progressively enhance the user interface with generated images from a Paint Worklet only when support is present.

Recap

There are various code snippets and concepts which are worth being familiar with to use this API. Here’s a quick recap:

  • The addModule method, e.g. CSS.paintWorklet.addModule('worklet.js'), triggers the resource download for the file worklet.js. You invoke this from your main JavaScript code
  • To reference your Paint Worklet from CSS code, use the paint function like this: paint(my-paint-worklet)
  • The paint method of a Paint Worklet receives three arguments. 1) A canvas context. 2) Geometry information. 3) Input properties
  • Simply importing your paint worklet module is not enough, you must also register it with the registerPaint function available within the Worklet, like this: registerPaint('my-paint-worklet', MyWorklet)
  • Input properties must get returned in an array from invoking the static inputProperties() method of the paint worklet
  • Your Paint Worklet can be executed multiple times and gets based upon when the browser determines a repaint is necessary

The API is admittedly a bit strange given how some features are global functions and others hook off of a global CSS namespace.

Further reading

If you’re interested in researching more on the CSS Paint API, check out these resources:

Conclusion

There are multiple topics involved in what is a seemingly simple task: to paint an image to the screen. However, the CSS Paint API allows you to create images which are performant, responsive and reactive.

Next time you get tasked with applying a complex background image onto a page, consider the pros and cons of using the CSS Paint API to programmatically create a resolution independent image instead of a potentially large image resource.

Are you looking for helping build applications that leverage modern best practices and features such as the CSS Paint API? Contact us to discuss how we can help!