
Exploring the CSS Paint API

CSS Paint API is the first part of the Houdini project that is available in the stable version of the browser. It Google Chrome team added it to Chrome 65 on March 6th. That is why it is an excellent time to try it out and start experimenting. I want you to get started and start own experimenting with it.
What is Houdini?
Before we start the exploration of CSS Paint API, let me made a short intro to the Houdini project. I will not get into too many details but will provide you with links to resources to learn more if you want.
So Houdini is the set of APIs that allows you to interact with CSS engine internals. Unfortunately, until this time we are limited to works with some part of CSSOM (CSS Object Model) via JavaScript. So we can try polyfill CSS with JS but after browser renders stage. And after changes browser needs to perform rendering of the screen again. But with Houdini, we can extend styles in the same way we do with JS.
Houdini APIs are here to work with CSS parser, CSSOM, cascade, layout, paint, and composite rendering stages. There are two main groups of the API: CSS properties & values and worklets. And worklets cover renders states access: layout, paint, and composite. While properties and values focused on parser extension, work with CSSOM and cascade. You can check browsers implementation status for each API here and more information about it here.
CSS Paint Worklet
CSS Paint API is the kind of worklets and how you could understand the name it works with paint rendering process. What does it do? It allows you to create custom CSS function to draw an image as background with JavaScript. And then use this function for any CSS property that expects image. For example, you can use it for background-image
, border-image
or list-style-image
. But more exciting that it also could be used for custom CSS property, we will come back to them later.
For drawing pictures with JavaScript, you allowed using the limited version of Canvas API. Why limited? For security reasons, you are not able to read pixels from an image or render text. But you can draw arcs, rectangles, paths, etc.
Why we need CSS Paint API?
There are a few use cases that I have in my mind for now:
CSS polyfills – of course, we could write a polyfill for CSS with JavaScript, but it is not a good idea in case of usability and performance. You can read some thoughts about that here. But CSS Paint is a good candidate for that, for example, take a look on
conic-gradient
polyfill example.Reduce DOM nodes number – sometimes we need to add dummy DOM nodes, like
span
just for visuals. Also, some of the animations may require additional elements. Take a look at the painter that implements Material Design “ripple” animation. In original Material Design library, it creates two additionalspan
elements for that animation and with worklet no need to do so. Now imagine you have ten buttons with “ripple” effect on the page, and CSS paint saves you twenty DOM nodes for that.Fancy backgrounds – you can create some kind of new experience for end users with unusual patterns and backgrounds. And good thing here that they will not affect performance and could be used as a part of progressive enhancement.
How to use CSS paint in styles?
To use custom paint in your stylesheets you need to use paint
function, and pass your paint name, as well as any required arguments next. Here the example how it could look like:
div {
background-image: paint(my-custom-paint);
}
In the case above we are using custom paint with name my-custom-paint
, next let’s imagine that it allows us to pass additional arguments inside, like color:
div {
background-image: paint(my-custom-paint, #fff);
}
Looks similar too what we have with some CSS built-in functions, like linear-gradient
:
div {
background-image: linear-gradient(to bottom, #fff, #000);
}
As paint
is just a value of CSS declaration it will be easy to fallback it for old browsers with solid color or image:
.paint-with-fallback {
background-image: url('./my-paint-fallback.jpg');
background-image: paint(my-custom-paint);
}
Or we can check browser support in CSS:
.paint-with-fallback {
background-image: url('./my-paint-fallback.jpg');
}
@supports(background-image: paint(id)) {
.paint-with-fallback {
background-image: paint(my-custom-paint);
}
}
In this case, browsers that don’t support the CSS Paint API will ignore last background-image
declaration and use some static image instead.
But if we will create some wide-used painter, it will be great to automate fallback insertion, as humans could forget about it. And I have great news for you, PostCSS could do it for us with a plugin. To write such a plugin, we don’t need a lot of lines of code. PostCSS provide us with a bunch of handy helper tools to iterate through CSS AST (Abstract Syntax Tree) and manipulate it. Below is the example of such plugin that replaces custom paint with a static fallback value passed as a fallbackValue
option:
const postcss = require('postcss');
module.exports = postcss.plugin(
'postcss-fallback-my-paint',
options => {
return css => {
css.walkRules(rule => {
rule.walkDecls(decl => {
const value = decl.value;
if (value.includes('my-custom-paint')) {
decl.cloneBefore({value: options.fallbackValue});
}
});
});
};
}
);
This plugin will walk through all CSS rules and then all declarations inside them. The look for my-css-paint
calls and insert clones declaration before with value replaced to fallback. Not the 🚀 science, isn’t it?
How to create custom CSS paint?
So how to create custom CSS paint? It is just three steps:
- Declare a custom paint class
- Register paint
- Load worklet
So, first of all, we want to declare CSS Paint class. It should be a JavaScript class with paint
method. We will explore this method and its arguments later, for now just look at the underlying implementation:
class MyCustomPainter {
paint(ctx, geom, props, args) {
// paint implementation.
}
}
After that we need to register the newly defined painter:
registerPaint('my-custom-paint', MyCustomPainter);
We were using registerPaint
function and pass paint name as the first argument and our class reference as the second. Here I want to notice that our paint module file with class and registration call has a separate context. That means that we can’t access any function or variable available in global browser scope or even load any dependency script.
Next, the last step is to load worklet, so after that, you will be able to use it in your stylesheets:
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('my-custom-paint.js');
}
First, we are checking if paintWorklet
available in browser and then register our custom paint calling the only available method on CSS.paintWorklet
called addModule
. It accepts one parameter – path to our worklet JavaScript file. Here you can also opt-in with JavaScript-based fallback for CSS Paint with additional else
statement.
Here how should look the final result:
// my-custom-paint.js
class MyCustomPainter {
paint(ctx, geom, props, args) {
// paint implementation.
}
}
registerPaint('my-custom-paint', MyCustomPainter);
// script loaded on page - script.js
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('my-custom-paint.js');
}
Practice
After the introduction to Paint API, the best idea is to try it. Let’s start with the primary example – create painter that will draw few circles as background. To get started let’s define a class for paint and register it:
// paint.js
class CirclesPainter {
paint(ctx, geom) {
const offset = 10;
const size = Math.min(geom.width, geom.height);
const radius = (size / 4) - offset;
const point = radius + offset;
for (let i = 0; i < 2; i++) {
for (let j = 0; j < 2; j++) {
ctx.fillStyle = `rgb(0, ${Math.floor(255 - 42.5 * i)}, ${Math.floor(255 - 42.5 * j)})`;
ctx.beginPath();
ctx.arc(point + (i * (point * 2)), point + (j * (point * 2)), radius, 0, 2 * Math.PI);
ctx.fill();
}
}
}
}
registerPaint('circles', CirclesPainter);
We created CirclesPainter
class with the paint
method. This method accepting two arguments: ctx
which is our canvas context and geom
object that consists of 2 properties. geom
contains the width
and height
of our canvas surface. Then using our context, we draw four circles inside loops and fill them with some shades of blue. And finally we load our worklet on the page:
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('paint.js');
}
To use it we created simple div
with class name circles
and added next rules to our stylesheet:
.circles {
overflow: hidden;
height: 0;
padding-top: 50%;
background: #000;
background: paint(circles);
}
So we make it square and add black color as a fallback for old browsers. That is it! You can check result and code on GitHub. Here is the demo: