CSS Houdini: Painting Custom Properties with the Paint API
- CSS
- Houdini
- Paint API
- Custom Properties
- Web APIs
- Frontend
What Is CSS Houdini
Ever tried to create a custom background pattern in CSS and ended up with a mess of stacked gradients, pseudo-elements, and SVG data URIs? Or wanted to animate a decorative border but hit the wall because CSS doesn't let you define how things are painted?
That's the problem CSS Houdini solves. It's a collection of low-level browser APIs that give developers direct access to the CSS rendering engine — the same engine that draws your gradients, layouts, and animations. Instead of waiting for browser vendors to ship new CSS features, you can write your own.
The most visually powerful piece is the Paint API. It lets you register a JavaScript class — called a paint worklet — that the browser calls whenever it needs to paint a CSS property like background-image, border-image, or mask-image. The result: fully custom visual effects that live natively inside CSS, respond to custom properties, and animate with @keyframes — all without touching the DOM.
The Paint API Explained
The Paint API has two parts: a worklet file that runs off the main thread, and a registration call in your main JavaScript.
Here's the lifecycle:
- You write a class with a
paint(ctx, geom, properties)method - You call
registerPaint('my-painter', MyPainter)inside the worklet file - In your main JS, you load the worklet with
CSS.paintWorklet.addModule('my-painter.js') - In CSS, you use
background: paint(my-painter)on any element - The browser calls your
paint()method whenever that element needs repainting — on resize, on custom property change, on scroll if needed
The key insight: the browser calls paint(), not you. Your worklet is purely reactive. It receives a PaintRenderingContext2D (a subset of Canvas 2D — no getImageData, putImageData, toDataURL, or text-rendering methods), the element's geometry, and any CSS custom properties you declared as inputs.
Writing a Paint Worklet
// noise-painter.js — runs in the paint worklet context
class NoisePainter {
// Declare which CSS custom properties this painter reads
static get inputProperties() {
return ['--noise-density', '--noise-color', '--noise-opacity'];
}
paint(ctx, geom, properties) {
const density = parseFloat(properties.get('--noise-density')) || 16;
const color = properties.get('--noise-color').toString().trim() || '#00e5b0';
const opacity = parseFloat(properties.get('--noise-opacity')) || 0.6;
const cols = Math.ceil(geom.width / density);
const rows = Math.ceil(geom.height / density);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (Math.random() > 0.5) continue;
ctx.globalAlpha = Math.random() * opacity;
ctx.fillStyle = color;
ctx.fillRect(x * density, y * density, density - 1, density - 1);
}
}
}
}
registerPaint('noise-painter', NoisePainter);Notice static get inputProperties() — this is a getter, not a regular method. It tells the browser which CSS custom properties to watch. When any of them change, the browser re-invokes paint() automatically.
Registering and Using the Worklet
Loading a worklet is asynchronous. CSS.paintWorklet.addModule() returns a Promise:
// main.js — runs on the main thread
if ('paintWorklet' in CSS) {
await CSS.paintWorklet.addModule('./noise-painter.js');
}Then use it in your CSS like any other image value:
.my-element {
background: paint(noise-painter);
--noise-density: 16;
--noise-color: #00e5b0;
--noise-opacity: 0.6;
}Making Custom Properties Animatable
The @property rule is what makes custom properties animatable. Without it, transition and @keyframes treat custom properties as discrete — they snap instead of interpolating. Register the syntax type and the browser handles smooth interpolation.
@property --noise-density {
syntax: '<number>';
inherits: false;
initial-value: 16;
}
.my-element {
background: paint(noise-painter);
--noise-density: 16;
transition: --noise-density 0.4s ease;
}
.my-element:hover {
--noise-density: 8; /* smoothly animates the painter */
}By registering --noise-density with syntax: '<number>', the browser knows how to interpolate between values. Your paint worklet gets called on every animation frame with the intermediate value — smooth, GPU-friendly animations driven purely by CSS.
Live Paint Demos
What Can You Paint
Procedural noise, grids, gradients, patterns — anything you can draw on a Canvas 2D context, rendered as a CSS background.
Custom animated borders with border-image: paint(my-painter) — no SVG gymnastics required.
Procedural masks for text reveals, image transitions, and custom clip shapes using mask-image: paint(my-mask).
Because painters read CSS custom properties, @keyframes and transition drive them for free — no JavaScript animation loop needed.
Browser Support
Support is currently Chromium-only (Chrome, Edge, Opera). Firefox has no plans to implement the Paint API, and Safari hasn't shipped it. The css-paint-polyfill from Google Chrome Labs covers most use cases for unsupported browsers, but it runs on the main thread and loses the performance benefits of a real worklet. Always feature-detect before using:
// JavaScript feature detection
if ('paintWorklet' in CSS) {
await CSS.paintWorklet.addModule('./my-painter.js');
}/* CSS feature detection */
@supports (background: paint(id)) {
.element {
background: paint(my-painter);
}
}Worklet Limitations
Paint worklets have no access to the DOM, window, document, or any Web APIs like fetch. They cannot maintain state between calls — each invocation of paint() must be self-contained. The only communication channel is CSS custom properties passed via inputProperties. This is by design: worklets must be thread-safe and compositable, because the browser may run them on any thread, at any time, and may even discard and recreate them.
Not available in PaintRenderingContext2D: getImageData, putImageData, toDataURL, measureText, fillText, strokeText.
Progressive Enhancement
Always provide a CSS fallback. Use @supports to layer in paint worklet effects only where supported. Your site should look good without Houdini — paint worklets are a progressive enhancement, not a dependency.
.hero {
/* Fallback: works everywhere */
background: linear-gradient(135deg, #0f1022, #1a1040);
}
@supports (background: paint(id)) {
.hero {
/* Enhancement: paint worklet for supported browsers */
background: paint(noise-painter);
--noise-density: 12;
--noise-color: #00e5b0;
--noise-opacity: 0.4;
}
}Getting Started
Create your worklet file
Write a class with static get inputProperties() and a paint(ctx, geom, props) method. Call registerPaint() at the end. Keep it simple — start with filled rectangles or circles before going procedural.
Register the worklet
In your main JavaScript, call await CSS.paintWorklet.addModule('./my-painter.js') inside a 'paintWorklet' in CSS check. The file must be served as a separate module (not bundled).
Use it in CSS
Apply background: paint(your-painter-name) to any element. Set your custom properties inline or in a ruleset. Add @property rules for any values you want to animate.
Add fallbacks
Wrap your paint worklet styles in @supports (background: paint(id)). Provide a solid background or linear-gradient fallback above it for unsupported browsers.