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:
1namespace 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:
1declare 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:
1declare interface Paint {2 paint(ctx: PaintRenderingContext, size: PaintSize, styleMap: StylePropertyMapReadOnly): void3}4
5type PaintCtor = new () => Paint6
7declare 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 transparency15 */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 passed22 * any values in the CSS23 */24 inputArguments?: string[]25}26
27type 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 */2declare type PaintRenderingContext = CanvasRenderingContext2D3
4declare type PaintSize = { height: number, width: number }
Defining a Worklet
A simple Paint class without any input properties looks something like this:
1export 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:
- From our main script, we need to run
CSS.paintWorklet.addModule
. I’m using the Viteurl
param to get this directly from the path to my worklet file:
1import workletUrl from './worklet?url'2
3CSS.paintWorklet.addModule(workletUrl)
- From the worklet, you need to register the
MyCustomPainter
as apainter
:
1import { MyCustomPainter } from "./my-painter";2
3MyCustomPainter.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:
1// ... rest of class2static readonly colorProp = '--custom-painter-color'3
4static registerProperties() {5 CSS.registerProperty({6 name: MyCustomPainter.colorProp,7 syntax: '<color>',8 inherits: false,9 initialValue: 'transparent',10 })11}12
13static 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:
1import { MyCustomPainter } from './my-painter'2import workletUrl from './worklet?url'3
4CSS.paintWorklet.addModule(workletUrl)5MyCustomPainter.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
1// ... rest of class2paint(ctx: PaintRenderingContext, size: PaintSize, styleMap: StylePropertyMapReadOnly) {3 const color = styleMap.get(MyCustomPainter.colorProp)!.toString()4
5 ctx.fillStyle = color6 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
1<h1 class="fancy">Hello world</h1>
1.fancy {2 --custom-painter-color: yellow;3 background-image: paint(myCustomPainter);4}
1export 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 = color29 ctx.fillRect(0, 0, size.width, size.height)30 }31}
1import { MyCustomPainter } from "./my-painter";2
3MyCustomPainter.registerPaint()
1import { MyCustomPainter } from './my-painter'2import workletUrl from './worklet?url'3
4MyCustomPainter.registerProperties()5CSS.paintWorklet.addModule(workletUrl)
1// Types for working with the CSS Paint API2
3namespace CSS {4 declare const paintWorklet: {5 addModule(url: string): Promise<void>6 }7}8
9declare function registerPaint(name: string, paintCtor: PainterOptions): Promise<void>10
11declare interface Paint {12 paint(ctx: PaintRenderingContext, size: PaintSize, styleMap: StylePropertyMapReadOnly): void13}14
15type PaintCtor = new () => Paint16
17declare 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 passed24 * any values in the CSS25 */26 inputArguments?: string[]27}28
29type PainterOptions = PainterClassRef & PaintCtor30
31declare type PaintRenderingContext = CanvasRenderingContext2D32
33declare 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