What Is the CSS Houdini Paint API?
The CSS Houdini Paint API is a low-level browser capability that allows developers to programmatically draw graphics and generate visual content directly on a CSS-referenced element's background, border, or content area. It is part of the larger CSS Houdini suite of APIs, which aim to expose the browser's internal rendering engine to developers, enabling custom styling logic that was previously impossible without external libraries or hacky workarounds.
With the Paint API, you write a paint worklet—a small JavaScript class executed inside a specialized worklet context—that can respond to CSS properties, element dimensions, and custom arguments. The browser calls your paint() method every time it needs to render that part of the element, handing you a CanvasRenderingContext2D-like drawing context and the element's geometry. You then draw whatever you want: patterns, gradients, noise textures, animated effects, or complex generative art—all rendered natively as part of the CSS compositing pipeline.
Key Characteristics
- Worklet-based execution: Paint worklets run on a separate thread (the worklet thread), ensuring smooth, non-blocking rendering.
- Canvas-like API: The drawing context provided is a subset of the familiar 2D canvas API, making it approachable for front-end developers.
- CSS-integrated: The output is applied via the
paint()CSS function inside properties likebackground-image,border-image, ormask-image. - Property-driven: Paint worklets can declare which CSS properties they observe, automatically re-rendering when those properties change.
- No DOM access: Worklets cannot access the DOM, network, or main thread APIs—they are sandboxed for performance and security.
Why the Paint API Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Before Houdini, creating truly custom, resolution-independent, and performant visual effects in CSS often meant resorting to large image assets (base64 data URIs, SVG background images, or external files), complex pseudo-element hacks, or JavaScript-driven canvas overlays. Each of these approaches has significant drawbacks:
- Image assets are static, non-adaptive, and increase page weight.
- SVG backgrounds require separate markup and cannot easily respond to live CSS property changes.
- Canvas overlays live outside the CSS compositing system, requiring manual synchronization with element resizing and scroll position.
The Paint API solves these problems by moving custom rendering into the CSS engine itself. Your custom graphics become first-class citizens of the styling pipeline: they participate in layout, respect element boundaries, composite correctly with other layers, and can respond dynamically to any CSS property you choose to observe. This leads to:
- Smaller bundles: No heavy image assets—just a few kilobytes of JavaScript.
- Infinite scalability: Vector-like drawing that automatically adjusts to element size, device pixel ratio, and zoom level.
- Dynamic responsiveness: Live updates when custom properties, dimensions, or observed CSS properties change.
- Animation potential: Combined with CSS animations or
requestAnimationFrame-style timing inside the worklet (viaAnimationWorkletor custom property interpolation), you can create animated textures without repainting the main thread.
Core Concepts and Architecture
Understanding the Paint API requires grasping four interconnected pieces:
1. The Paint Worklet File
A paint worklet is a standalone JavaScript file (or a blob/string registered via CSS.paintWorklet.addModule()). It must contain at least one class registered with registerPaint(). This file runs in a dedicated worklet context with limited API surface.
2. The registerPaint() Function
This global function (available only inside worklet scripts) associates a class with a name. The name is what you'll reference in CSS. It accepts two arguments: the name string and the class definition.
3. The Paint Class
Your class must implement:
paint(ctx, geometry, properties, args)— the required drawing method.- Optionally,
static get inputProperties()— returns an array of CSS property names to observe. - Optionally,
static get inputArguments()— defines the types of arguments passed via the CSSpaint()function.
4. The CSS paint() Function
Used inside standard CSS properties, it references the registered paint worklet by name and optionally passes additional arguments.
Getting Started: Setting Up a Paint Worklet
Let's walk through building your first paint worklet from scratch. We'll create a simple checkerboard pattern that responds to element size and a custom CSS property for cell size.
Step 1: Create the Worklet File
Create a file called checkerboard-worklet.js. This file will contain the paint class and the registration call.
// checkerboard-worklet.js
class CheckerboardPainter {
// Declare which CSS properties this worklet observes
static get inputProperties() {
return [
'--checkerboard-cell-size',
'--checkerboard-color-a',
'--checkerboard-color-b'
];
}
paint(ctx, geometry, properties) {
// Read custom properties, with fallback defaults
const cellSize = parseInt(
properties.get('--checkerboard-cell-size').toString()
) || 30;
const colorA = properties.get('--checkerboard-color-a').toString()
.trim() || '#ffffff';
const colorB = properties.get('--checkerboard-color-b').toString()
.trim() || '#cccccc';
const width = geometry.width;
const height = geometry.height;
// Calculate number of columns and rows
const cols = Math.ceil(width / cellSize);
const rows = Math.ceil(height / cellSize);
// Draw the checkerboard
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
// Alternate colors based on row + col parity
ctx.fillStyle = (row + col) % 2 === 0 ? colorA : colorB;
ctx.fillRect(
col * cellSize,
row * cellSize,
cellSize,
cellSize
);
}
}
}
}
// Register the paint worklet under the name 'checkerboard'
registerPaint('checkerboard', CheckerboardPainter);
Notice how the paint() method receives four arguments:
ctx: APaintRenderingContext2Dobject (subset of Canvas 2D context).geometry: An object withwidthandheightrepresenting the target drawing area.properties: AStylePropertyMapReadOnlyproviding access to the declared input properties.args: (Not used here) Arguments passed directly from the CSSpaint()function call.
Step 2: Register the Worklet from Your Main Script
In your main HTML or JavaScript file, load the worklet module using the CSS.paintWorklet.addModule() method. This is a promise-based call that resolves once the worklet is loaded and the paint name is available.
<script>
// Check if the Paint API is supported
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('./checkerboard-worklet.js')
.then(() => {
console.log('Checkerboard paint worklet loaded successfully.');
})
.catch(err => {
console.error('Failed to load paint worklet:', err);
});
} else {
console.warn('CSS Paint API not supported in this browser.');
// Provide a fallback (e.g., a regular CSS background)
}
</script>
Step 3: Apply the Paint Worklet in CSS
Now reference the registered paint name using the paint() CSS function. You can use it inside background-image, border-image, mask-image, or any property that accepts images.
/* styles.css */
.checkerboard-box {
/* Define custom properties the worklet observes */
--checkerboard-cell-size: 40px;
--checkerboard-color-a: #3b82f6;
--checkerboard-color-b: #eff6ff;
/* Use the paint worklet as a background image */
background-image: paint(checkerboard);
/* Standard background styling still applies */
background-size: auto; /* Not needed but harmless */
background-repeat: no-repeat;
width: 300px;
height: 200px;
border-radius: 12px;
border: 2px solid #1e40af;
}
The browser will now render the checkerboard pattern directly into the element's background layer. Resizing the element or changing any of the custom properties triggers an automatic repaint—no JavaScript intervention needed on the main thread.
Practical Examples
Example 1: Animated Noise Texture
This worklet generates a grayscale noise pattern that can be animated by interpolating a CSS custom property. We'll use the --noise-seed property to drive variation over time.
// noise-worklet.js
class NoisePainter {
static get inputProperties() {
return ['--noise-seed', '--noise-density'];
}
paint(ctx, geometry, properties) {
const seed = parseFloat(
properties.get('--noise-seed').toString()
) || 0;
const density = parseFloat(
properties.get('--noise-density').toString()
) || 1;
const width = geometry.width;
const height = geometry.height;
// Create image data for pixel-level manipulation
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
// Simple pseudo-random function using the seed
function pseudoRandom(x, y, s) {
const hash = (x * 374761393 + y * 668265263 + s * 1274126177) & 0x7fffffff;
return (hash % 256) / 255;
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const noise = pseudoRandom(Math.floor(x / density), Math.floor(y / density), seed);
const gray = Math.floor(noise * 255);
data[idx] = gray; // R
data[idx + 1] = gray; // G
data[idx + 2] = gray; // B
data[idx + 3] = 255; // A
}
}
ctx.putImageData(imageData, 0, 0);
}
}
registerPaint('noise', NoisePainter);
Apply it with a CSS animation that cycles the seed value:
.noise-background {
--noise-seed: 0;
--noise-density: 2;
background-image: paint(noise);
animation: noise-shift 2s linear infinite;
}
@keyframes noise-shift {
0% { --noise-seed: 0; }
100% { --noise-seed: 100; }
}
Each time --noise-seed changes during the animation, the worklet repaints with a new noise pattern, creating a living, shimmering texture effect.
Example 2: Custom Squiggly Border
The Paint API can also be used with border-image. This example draws a hand-drawn-style squiggly line border.
// squiggly-border-worklet.js
class SquigglyBorderPainter {
static get inputProperties() {
return [
'--squiggly-amplitude',
'--squiggly-frequency',
'--squiggly-color',
'--squiggly-thickness'
];
}
paint(ctx, geometry, properties) {
const amplitude = parseFloat(
properties.get('--squiggly-amplitude').toString()
) || 4;
const frequency = parseFloat(
properties.get('--squiggly-frequency').toString()
) || 10;
const color = properties.get('--squiggly-color').toString()
.trim() || '#000000';
const thickness = parseFloat(
properties.get('--squiggly-thickness').toString()
) || 2;
const w = geometry.width;
const h = geometry.height;
ctx.strokeStyle = color;
ctx.lineWidth = thickness;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Draw a rectangle path with sinusoidal displacement
ctx.beginPath();
// Top edge (left to right)
for (let x = 0; x <= w; x += 2) {
const y = amplitude * Math.sin((x / w) * frequency * Math.PI * 2);
if (x === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
// Right edge (top to bottom)
for (let y = 0; y <= h; y += 2) {
const x = w + amplitude * Math.sin((y / h) * frequency * Math.PI * 2 + Math.PI / 2);
ctx.lineTo(x, y);
}
// Bottom edge (right to left)
for (let x = w; x >= 0; x -= 2) {
const y = h + amplitude * Math.sin((x / w) * frequency * Math.PI * 2 + Math.PI);
ctx.lineTo(x, y);
}
// Left edge (bottom to top)
for (let y = h; y >= 0; y -= 2) {
const x = amplitude * Math.sin((y / h) * frequency * Math.PI * 2 + (3 * Math.PI / 2));
ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
}
registerPaint('squiggly-border', SquigglyBorderPainter);
CSS usage with border-image:
.squiggly-box {
--squiggly-amplitude: 6;
--squiggly-frequency: 8;
--squiggly-color: #e11d48;
--squiggly-thickness: 3;
border: 20px solid transparent;
border-image: paint(squiggly-border) 20 fill / 20px;
/* The 'fill' keyword makes the paint fill the border area */
background: #fce7f3;
width: 280px;
height: 180px;
}
Example 3: Responsive Polka Dot Grid with Arguments
Sometimes you want to pass arguments directly in the CSS paint() function rather than via custom properties. This is useful for simple numeric or string parameters. Declare them via inputArguments and access them in the args parameter.
// polka-worklet.js
class PolkaDotPainter {
static get inputArguments() {
return [
'<number>', // dot radius
'<color>', // dot color
'<number>' // spacing multiplier
];
}
paint(ctx, geometry, properties, args) {
// args is an array of CSSUnparsedValue or CSSStyleValue objects
const dotRadius = args[0] ? parseFloat(args[0].toString()) : 10;
const dotColor = args[1] ? args[1].toString().trim() : '#000000';
const spacing = args[2] ? parseFloat(args[2].toString()) : 30;
const width = geometry.width;
const height = geometry.height;
ctx.fillStyle = dotColor;
for (let y = spacing / 2; y < height; y += spacing) {
for (let x = spacing / 2; x < width; x += spacing) {
ctx.beginPath();
ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
registerPaint('polka-dots', PolkaDotPainter);
CSS usage with inline arguments:
.polka-background {
/* paint(name, arg1, arg2, arg3) */
background-image: paint(polka-dots, 12, #7c3aed, 50);
width: 400px;
height: 250px;
}
Input Properties Deep Dive
The inputProperties static getter is one of the most powerful features of the Paint API. It creates a reactive binding: whenever any of the listed properties change on the element, the worklet automatically repaints. You can observe:
- Custom properties (e.g.,
--my-color)—the most common use case. - Standard CSS properties (e.g.,
background-color,font-size,border-width). - Any combination of both custom and standard properties.
The properties object passed to paint() is a StylePropertyMapReadOnly. You access values using properties.get('property-name'), which returns a CSSStyleValue or CSSUnparsedValue. You typically convert it with .toString() and then parse it as needed.
Property Type Handling Example
// Inside paint() method
const rawValue = properties.get('--my-custom-length');
// For numeric values, toString() gives the CSS unit string (e.g., "20px")
const asString = rawValue.toString(); // "20px"
const numericValue = parseFloat(asString); // 20
// For colors, you get the resolved CSS color string
const colorRaw = properties.get('--my-color').toString(); // "rgb(255, 0, 0)"
Remember that custom properties are not resolved to computed values automatically—they are passed as declared. If you need the computed value, observe a standard property instead or perform manual resolution inside the worklet.
Best Practices
1. Keep Worklets Small and Focused
Paint worklets should do one thing well. Avoid monolithic worklets that try to handle multiple unrelated patterns. Instead, create separate worklet files for each distinct visual effect. This improves maintainability and allows finer-grained loading.
2. Always Provide Fallbacks
Not all browsers support the Paint API. Always check for support and provide a traditional CSS fallback:
.my-element {
/* Fallback: a simple gradient or solid color */
background: linear-gradient(135deg, #667eea, #764ba2);
/* Enhanced: custom paint worklet */
background-image: paint(my-custom-pattern);
}
/* With @supports */
@supports (background-image: paint(my-custom-pattern)) {
.my-element {
background: none;
background-image: paint(my-custom-pattern);
}
}
3. Respect the Device Pixel Ratio
The geometry object provides dimensions in CSS pixels. For crisp rendering on high-DPI displays, multiply coordinates by devicePixelRatio when creating image data or when precision matters. However, for most vector-like drawing (paths, arcs, rects), the browser handles scaling automatically.
// For pixel-level work, account for device pixel ratio
const dpr = 1; // Worklet context doesn't expose window.devicePixelRatio
// Instead, use geometry.width/height which are already in CSS pixels
// The browser scales the canvas internally based on the actual resolution
4. Minimize Expensive Operations
The paint() method can be called frequently—on resize, scroll, animation frames, or property changes. Optimize your drawing code:
- Pre-calculate values outside loops.
- Use simple integer arithmetic instead of heavy math functions where possible.
- Avoid creating large temporary arrays inside
paint()unless necessary. - For static patterns that don't change with element size, consider caching strategies (though worklet instances are generally fresh each call).
5. Use inputArguments for Static Configuration
If certain parameters are set once and never change (or change rarely via CSS class swaps), pass them as arguments in the paint() CSS function rather than custom properties. Custom properties are better for values that animate or change dynamically at runtime.
6. Test Across Rendering Contexts
Paint worklets behave differently depending on the CSS property they're applied to:
background-image: Draws the entire background area, respectingbackground-sizeandbackground-repeat.border-image: Draws within the border box area, divided into nine regions per the border-image slicing model.mask-image: Draws a mask where alpha values determine transparency.
Test your worklet in all intended contexts to ensure the geometry and rendering behave as expected.
7. Handle Empty or Invalid Property Values Gracefully
Always provide sensible defaults when reading custom properties. Use || fallbacks, parseInt/parseFloat with default values, and validate color strings before using them.
// Robust property reading pattern
const cellSize = parseInt(
properties.get('--cell-size').toString()
);
const safeCellSize = Number.isNaN(cellSize) || cellSize <= 0 ? 30 : cellSize;
Browser Support and Progressive Enhancement
As of 2025, the CSS Paint API is supported in Chromium-based browsers (Chrome, Edge, Opera) with full functionality. Firefox and Safari have not yet shipped the Paint API, though the Houdini task force continues to work on cross-browser standardization.
For production applications, adopt a progressive enhancement strategy:
<script>
const supportsPaintAPI = 'paintWorklet' in CSS &&
CSS.paintWorklet &&
typeof CSS.paintWorklet.addModule === 'function';
if (supportsPaintAPI) {
CSS.paintWorklet.addModule('./my-worklet.js');
}
</script>
Combine this with CSS fallbacks as shown earlier. The experience degrades gracefully—users on non-supporting browsers simply see the static fallback background, while supported browsers enjoy the dynamic, worklet-powered visuals.
For development and debugging, Chrome DevTools provides worklet inspection capabilities. You can set breakpoints inside paint worklets, inspect the paint context, and profile rendering performance through the Performance panel.
Conclusion
The CSS Houdini Paint API represents a fundamental shift in how we approach visual design on the web. By moving custom rendering into the browser's own styling pipeline, it eliminates the performance penalties, asset overhead, and synchronization headaches of traditional custom graphics techniques. Whether you're building subtle noise textures, animated backgrounds, custom border treatments, or full-blown generative art, the Paint API offers a clean, property-driven, and remarkably efficient path forward.
The key to success lies in thinking in terms of reactive, property-driven painting functions rather than static assets. When you embrace the worklet model—small, focused, sandboxed drawing routines that respond to CSS property changes—you unlock a level of visual expressiveness that feels native to the web platform. Start with simple patterns, build up your worklet library, and always pair cutting-edge Houdini features with robust fallbacks. The future of CSS is programmable, and the Paint API is your brush.