CSS Paint API — New and Glossy, but What Is It For?

Published Mar 07, 2018Last updated May 19, 2018
CSS Paint API — New and Glossy, but What Is It For?

Photo by RhondaK Native Florida Folk Artist on Unsplash

Ever wanted to really delve into what CSS is doing? Ever wanted to customize how the browser renders a background or a mask-image? Probably not, but there are good reasons why you might want to.

The CSS Paint API will be available in the upcoming release of Chrome 65[1]. It is the first example of a spec developed by the CSS-TAG Houdini Task Force appearing in the production version of a major browser.

In this article, I hope to give a brief overview of what the CSS Paint API is, how we can use it, and what problems it is designed to solve.

CSS Houdini Group

The CSS Houdini Group has given itself an interesting objective — to open up the closed box that is styling and layout on the web. In other words, to reveal the tricks behind the magic that is browser rendering.

CSS Paint API is one of several APIs that they are developing. Some are still drafts, some are barely ideas, but they all relate to different parts of the browser rendering pipeline (Houdini Specs).


Looking specifically at the CSS Paint API, we now have a 2D rendering context that we can programmatically paint with JavaScript and apply to any CSS property that accepts an image as a value, i.e. background-image, border-image, mask-image.

To create this rendering context, we create a paint worklet. A worklet is similar to a worker in that it runs in parallel to the main thread but it has a very restricted functionality to allow further optimization.

Let's have a look at how this works in practice.

Anatomy of a Paint Worklet

At its simplest, a Paint Worklet is a class with a single method — the action the worklet will perform. In the case of the CSS Paint API, the single action is paint.

So, for example, to draw a red background fill on an elemeent, we would have a paint-red-fill.js file with the paint worklet:

 * The Paint Worklet is a simple class that must provide a paint method. 
 * This is the action the worklet does.
class RedFillPaintWorklet {
     * @param ctx - 2D Rendering Context
     * @pararm geom - dimensions (width, height) of the element
  paint(ctx, geom) {
    	// We can draw the context using most of the Canvas context apis
        // e.g. fill, stroke, rect, arc, etc.
        ctx.fillStyle = 'red';
        ctx.fillRect(0, 0, geom.width, geom.height);
// We then register the paint worklet. This makes it available in CSS.
registerPaint('red-fill', RedFillPaintWorklet);

The 2D rendering context is very similar to a CanvasRenderingContext2D, although it is a simplified version of that API. According to the spec, that means no CanvasHitRegion, CanvasImageData, CanvasUserInterface, CanvasText, or CanvasTextDrawingStyles APIs. In other words, no text, no hit area or focus rectangle, and no per-pixel manipulation.

To use the worklet, we load it and use the paint function in our stylesheet:

  .red-background {
    	background: paint(red-fill);
<div class='red-background'></div>

The above example is very simple. It just fills the full dimensions of the element with a solid red rectangle. It is equivalent to: background: red;.

As well as the 2D rendering context and the dimensions of the element, you can pass several more arguments to the paint function.

class FillPaintWorklet {
  // Register input properties to make them available to the paint method.
  static get inputProperties() { return ['--fill-colour']; }
  paint(ctx, geom, properties) {
    	const colour = properties.get('--fill-colour');
        ctx.fillStyle = colour;
        ctx.fillRect(0, 0, geom.width, geom.height);

registerPaint('colour-fill', ColourFillPaintWorklet);
  .fill-background {
    	--fill-colour: #00ff00;
    	background: paint(colour-fill);
<div class="fill-background"></div>

The interesting thing isn't necessarily that you have a canvas context that you can paint whatever 2D image you want in, it is how integrated with CSS it is.

For example, you can have one paint worklet and change CSS variables to adjust it. You can also use several paint worklets and multiple background images to build up something more complex. We can use the previous paint worklet in multiple ways:

        .paint-fill-0 {
            --fill-colour: #fff000;
            --gradient-colour: #000fff;
        .paint-fill-1 {
            --fill-colour: #ff9900;
            --gradient-colour: #0099ff;
        .paint-fill-2 {
            --fill-colour: #00ff00;
            --gradient-colour: #ff0000;
        .paint-fill {
            display: block;
            width: 100px;
            height: 100px;
            background: radial-gradient(circle at 50%, var(--gradient-colour, red) 25%, transparent 26%), paint(colour-fill);

Use Cases

The Houdini Taskforce has proposed several use cases for their APIs, including:

  • CSS Polyfills.
  • Cutting edge/experiments.
  • Performance boosts and DOM simplification.


There are already some really good examples on the web of what the CSS Paint API can and will be able to do.

  • Conic gradient polyfill — a good example of where the API can replace JavaScript polyfills of unsupported CSS features. By rendering off the main thread, performance should be greatly improved.

  • Ripple button — a good example of DOM simplification. Where this effect is usually created with mutliple DOM elements or at least some pseudo-elements, the effect can now be achieved in a single DOM element and, again, rendered in a worklet and off the main thread.

  • Parallax button — similar to the ripple button effect, using some simple canvas effects and integrating them with CSS variables for user interaction, we can create some interesting effects in a single DOM element.

class ParallaxPainter {
    static get inputProperties() { return ['--x-position']; }

    constructor() {
        this.layers = [
            {x: -0.1, y: 0.5, size: 0.3, speed: 0.15, colour: '#838C8D'},
            {x: 0.1, y: 0.5, size: 0.25, speed: 0.20, colour: '#9EA5A7'},
            {x: 0.8, y: 0.5, size: 0.3, speed: 0.15, colour: '#838C8D'},
            {x: 0.1, y: 1.0, size: 0.6, speed: 0.95, colour: '#DEE1E3'}

    paint(ctx, geom, properties) {
        const gradient = ctx.createLinearGradient(0, 0, 0, geom.height);
        gradient.addColorStop(0, '#444B4D');
        gradient.addColorStop(0.5, '#545B5D');
        gradient.addColorStop(0.501, '#838C8D');
        gradient.addColorStop(0.75, 'white');

        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, geom.width, geom.height);

        const xPosition = parseInt(properties.get('--x-position').toString());
        this.layers.forEach(item => this.drawMountain(ctx, geom, xPosition, item));

    drawMountain(ctx, geom, xPosition, {x, y, size, speed, colour}) {
        const xModifier = xPosition * speed;
        x = x * geom.width;
        y = y * geom.height;
        size = size * geom.width;

        ctx.fillStyle = colour;
        ctx.moveTo(x + xModifier, y - size);
        ctx.lineTo(x + xModifier + size, y);
        ctx.lineTo(x + xModifier - size, y);
        ctx.lineTo(x + xModifier, y - size);

registerPaint('parallax-paint', ParallaxPainter);

This parallax effect can be used with a button and the parallax controlled from mouse movement by updating the --x-position variable.

        :root {
            --x-position: 0;

        .button {
            border: 1px solid darkgray;
            border-radius: 50%;
            color: white;
            cursor: pointer;
            font-family: Roboto, sans-serif;
            font-size: 1.25rem;
            font-weight: 500;
            height: 8rem;
            line-height: 8rem;
            outline: none;
            text-align: center;
            user-select: none;
            vertical-align: middle;
            width: 8rem;

        .parallax {
            background: paint(parallax-paint);

        @media screen and (min-width: 30rem) {
            .button {
                height: 12rem;
                width: 12rem;
                line-height: 12rem;
    <button class="button parallax">CLICK ME!</button>

        const parallax = document.querySelector('.parallax');
        parallax.addEventListener('mousemove', event => {
            document.documentElement.style.setProperty('--x-position', event.clientX + 'px');

And here is the end result:


As this article is about the potential of an API that hasn't quite hit production in a single major browser yet, you can imagine that browser support is limited.

You can follow the progress of browser support for the CSS Paint API and the rest of the Houdini family of APIs at https://ishoudinireadyyet.com/.

The Chrome team definitely seems to be leading the way, but some development is happening in Firefox and there is intent from Safari, Chrome, and Firefox to develop several of the APIs, including: CSS Typed OM, Layout API, and Animation Worklets.

The CSS Properties and Values API will really complement and enhance the CSS Paint API. It will allow typed arguments (image, color, length, etc) to be passed to the paint function, which means a lot more can be done.


The CSS Paint API is in its early days. The more we see browser support increase and the more the other Houdini specs start to appear in modern browsers, the more we will see the potential of these APIs to speed up CSS development and improve CSS polyfill performance, and the more developers will have greater access and control over the browser rendering pipeline for experimental and creative purposes.

These are the beginnings of an exciting journey for anyone interested in how browsers render the web pages that they design and develop.

Further reading

  1. At the time the article was written, mid-Feb 2018, Chrome 65 was in Beta and due for release in March. ↩︎

Discover and read more posts from Brett Jephson
get started