Exploring the CSS Paint API

10 July 2025

Updated: 10 July 2025

The CSS Painting API is part of the CSS Houdini group of APIs which provide low level access to the CSS engine

The CSS Houdini APIs use the idea of “worklets” which are basically JS files that the CSS engine will use as part of it’s rendering pipeline

For the sake of this example, I’ll be looking specifically at the CSS Painting API to build a custom CSS paint worklet

The CSS paint function

Before getting into specifics around how to implement a worklet, it’s nice to see what we’re trying to get to. Worklets are effectively JS functions that we can “call” from our CSS code to modify how an element is rendered. The CSS Painting API exposes a paint function in CSS. Let’s assume we have a worklet called myCustomPainter

We would be able to apply this to some html code like so:

1
<h1 class="fancy">Hello world</h1>

And we can style this using the CSS paint function with the name of our worklet:

1
.fancy {
2
background-image: paint(myCustomPainter);
3
}

This will invoke our worklet to paint a custom background for our element

The Methods Available

The CSS Paint API exposes a few different methods and bits of functionality to us

Firstly, we have the methods needed for defining a worklet, this is the global CSS.paintWorklet.addModule method:

1
namespace CSS {
2
declare const paintWorklet: {
3
addModule(url: string): Promise<void>
4
}
5
}

Next, we also have the registerPaint function which is a global function in the Worklet scope that is used to register a class that handles painting:

1
declare function registerPaint(name: string, paintCtor: PainterOptions): Promise<void>

The actual PainterOptions consists of two parts, a Paint class that handles the actual painting, and a static PainterClassRef that specificies some metadata about the Paint class:

1
declare interface Paint {
2
paint(ctx: PaintRenderingContext, size: PaintSize, styleMap: StylePropertyMapReadOnly): void
3
}
4
5
type PaintCtor = new () => Paint
6
7
declare interface PainterClassRef {
8
/**
9
* CSS Properties accessed by the `paint` function. These can be normal or custom properties.
10
*/
11
inputProperties?: string[]
12
13
/**
14
* Specififes if the rendering context supports transparency
15
*/
16
contextOptions?: { alpha: boolean }
17
18
/**
19
* Inputs to the `paint` function from CSS.
20
* Not supported in any browsers I've tested.
21
* Chrome on MacOS will completely break rendering the `paint` function is passed
22
* any values in the CSS
23
*/
24
inputArguments?: string[]
25
}
26
27
type PainterOptions = PainterClassRef & PaintCtor

Lastly, for the sake of completeness, the remaining types used by the above definitions are:

1
/** Not exactly a Canvas but it's pretty similar */
2
declare type PaintRenderingContext = CanvasRenderingContext2D
3
4
declare type PaintSize = { height: number, width: number }

Defining a Worklet

A simple Paint class without any input properties looks something like this:

my-painter.ts
1
export class MyCustomPainter implements Paint {
2
static get contextOptions() {
3
return { alpha: true };
4
}
5
6
static registerPaint() {
7
registerPaint("myCustomPainter", MyCustomPainter);
8
}
9
10
paint(ctx: PaintRenderingContext, _size: PaintSize, styleMap: StylePropertyMapReadOnly) { }
11
}

The registerPaint method isn’t strictly necessary but it lets us keep the registration inside of the class which is nice

Registering this as a worklet is done in two steps:

  1. From our main script, we need to run CSS.paintWorklet.addModule. I’m using the Vite url param to get this directly from the path to my worklet file:
main.ts
1
import workletUrl from './worklet?url'
2
3
CSS.paintWorklet.addModule(workletUrl)
  1. From the worklet, you need to register the MyCustomPainter as a painter:
worklet.ts
1
import { MyCustomPainter } from "./my-painter";
2
3
MyCustomPainter.registerPaint()

Taking Inputs

In order for our worklet to do something fun we will probably want to take some inputs. We can specifiy which CSS properties (or custom properties) we want to use as an input - we do this via inputProperties. We can also register a custom property by using CSS.registerProperty

For our example, we’ll also define a method in the MyCustomPainter class that does this:

my-painter.ts
1
// ... rest of class
2
static readonly colorProp = '--custom-painter-color'
3
4
static registerProperties() {
5
CSS.registerProperty({
6
name: MyCustomPainter.colorProp,
7
syntax: '<color>',
8
inherits: false,
9
initialValue: 'transparent',
10
})
11
}
12
13
static get inputProperties() {
14
return [MyCustomPainter.colorProp]
15
}
16
17
// ... rest of class

Then, we need to call the registerProperties method above to register the custom property in the main.ts file (or somewhere in the normal page/JS context)

We can do this along with where we defined the worklet:

main.ts
1
import { MyCustomPainter } from './my-painter'
2
import workletUrl from './worklet?url'
3
4
CSS.paintWorklet.addModule(workletUrl)
5
MyCustomPainter.registerProperties()

While it should be possible to get inputs as arguments to a painter, it seems that is not supported on any browsers as yet

This will make it so that we can provide inputs when painting. Inputs can come from our CSS, so using our worklet now looks like this:

1
.fancy {
2
--custom-painter-color: yellow;
3
background-image: paint(myCustomPainter);
4
}

Then, we can access this in the paint method to do something like draw a rectangle over the entire canvas

my-painter.ts
1
// ... rest of class
2
paint(ctx: PaintRenderingContext, size: PaintSize, styleMap: StylePropertyMapReadOnly) {
3
const color = styleMap.get(MyCustomPainter.colorProp)!.toString()
4
5
ctx.fillStyle = color
6
ctx.fillRect(0, 0, size.width, size.height)
7
}

The paint method receives a rendering context, the size of the element, and the styles that we defined in registerProperties (if they are set on the element)

It’s also nice to note that since we’ve specified that --custom-painter-color has an initialValue it will not be undefined and the browser will provide us with the initialValue if it’s not provided

And that’s really about it. The API is pretty simple but powerful and makes it possible to do so much

The Complete Worklet

index.html
1
<h1 class="fancy">Hello world</h1>
style.css
1
.fancy {
2
--custom-painter-color: yellow;
3
background-image: paint(myCustomPainter);
4
}
my-painter.ts
1
export class MyCustomPainter implements Paint {
2
static readonly colorProp = '--custom-painter-color'
3
4
static registerProperties() {
5
CSS.registerProperty({
6
name: MyCustomPainter.colorProp,
7
syntax: '<color>',
8
inherits: false,
9
initialValue: 'transparent',
10
})
11
}
12
13
static registerPaint() {
14
registerPaint("myCustomPainter", MyCustomPainter);
15
}
16
17
static get inputProperties() {
18
return [MyCustomPainter.colorProp]
19
}
20
21
static get contextOptions() {
22
return { alpha: true };
23
}
24
25
paint(ctx: PaintRenderingContext, size: PaintSize, styleMap: StylePropertyMapReadOnly) {
26
const color = styleMap.get(MyCustomPainter.colorProp)!.toString()
27
28
ctx.fillStyle = color
29
ctx.fillRect(0, 0, size.width, size.height)
30
}
31
}
worklet.ts
1
import { MyCustomPainter } from "./my-painter";
2
3
MyCustomPainter.registerPaint()
main.ts
1
import { MyCustomPainter } from './my-painter'
2
import workletUrl from './worklet?url'
3
4
MyCustomPainter.registerProperties()
5
CSS.paintWorklet.addModule(workletUrl)
paint.d.ts
1
// Types for working with the CSS Paint API
2
3
namespace CSS {
4
declare const paintWorklet: {
5
addModule(url: string): Promise<void>
6
}
7
}
8
9
declare function registerPaint(name: string, paintCtor: PainterOptions): Promise<void>
10
11
declare interface Paint {
12
paint(ctx: PaintRenderingContext, size: PaintSize, styleMap: StylePropertyMapReadOnly): void
13
}
14
15
type PaintCtor = new () => Paint
16
17
declare interface PainterClassRef {
18
inputProperties?: string[]
19
contextOptions?: { alpha: boolean }
20
21
/**
22
* Not supported in any browsers I've tested.
23
* Chrome on MacOS will completely break rendering the `paint` function is passed
24
* any values in the CSS
25
*/
26
inputArguments?: string[]
27
}
28
29
type PainterOptions = PainterClassRef & PaintCtor
30
31
declare type PaintRenderingContext = CanvasRenderingContext2D
32
33
declare type PaintSize = { height: number, width: number }

References

There are loads of things you can do with the Houdini APIs, some things I recommend reading and taking a look at on this topic are:

Notes

It’s kinda annoying how many moving parts this has and that makes it a little challenging to include a live example on this blog. Hopefully the other examples I’ve linked above will serve this purpose

Some nice next things to look at from here are the other Houdini APIs since they offer very different sets of functionality and can be combined to do some interesting stuff