CSS Paint API: Connecting JavaScript Canvas to CSS `background-image` with `registerPaint()`
How have you handled complex background patterns in the past? Creating SVG files, exporting PNGs, swapping entire images for dark mode transitions — at some point you start wondering: "Can't I just draw background images directly in code?" I remember the first time I searched for CSS Houdini, thinking "what on earth is this," then closing the tab because the documentation was so vast and full of W3C spec links. The truth is, the part you actually need is fairly small.
Looking at just the CSS Paint API within CSS Houdini, the concept is straightforward. It's an API that lets you intercept the browser's paint stage — the moment it needs to draw a CSS image — and say "I'll handle this part with my own JavaScript function." This article covers everything from registering a custom Paint Worklet with registerPaint() to practical patterns for implementing animations driven by CSS variables. The key insight is that you can dynamically draw CSS background-image using pure Canvas API, with no image assets.
If you're new to Canvas, I recommend skimming through MDN's Canvas tutorial first. If you're not, by the end of this article you'll be able to create a Ripple animation on hover without a single line of JavaScript.
Core Concepts
How the CSS Paint API Hooks Into the Rendering Pipeline
When the browser renders a page, it goes through layout → paint → composite stages. Traditionally, developers declared styles in CSS and the browser handled the painting. The CSS Paint API lets us substitute our own function during the paint stage, specifically for properties that accept image values.
/* The browser calls a Worklet named 'checkerboard' to draw the image */
.element {
background-image: paint(checkerboard);
}You can use the paint() function anywhere a CSS property accepts an image value — background-image, border-image, mask-image, and so on. Think of it as putting a function name where you'd normally put a file path.
What is a Worklet? It's a lightweight execution context similar to a Web Worker. Paint Worklets are designed not to block the main thread, so complex patterns won't cause UI jank. That said, the threading model varies by implementation, and you can't assert there is "zero main thread overhead."
From Writing a registerPaint() Class to Calling It in CSS — 3 Steps
When you first create a Paint Worklet, it's easy to get confused about why the file needs to be separate and where registration happens. Here's the full flow broken down step by step.
Step 1: Write the Worklet File
It must be in a separate JS file. This is because the file runs in the Worklet execution context when loaded via addModule().
// my-painter.js (must be a separate file)
registerPaint('my-painter', class {
// Declare CSS variables to track. paint() is only re-invoked when values in this list change
static get inputProperties() {
return ['--my-color', '--my-size'];
}
// Actual drawing logic
paint(ctx, geometry, properties) {
const color = properties.get('--my-color').toString();
const size = parseInt(properties.get('--my-size')) || 20;
ctx.fillStyle = color;
ctx.fillRect(0, 0, geometry.width, geometry.height);
}
// (Optional) For passing arguments directly to the paint() CSS function
// In CSS: background-image: paint(my-painter, 10px, red);
static get inputArguments() {
return ['<length>', '<color>'];
}
});| Method | Role |
|---|---|
inputProperties() |
Declares CSS properties to track. Changing a variable not in this list will not trigger a paint() re-invocation |
paint(ctx, geometry, properties) |
The actual drawing function. Nearly identical to the Canvas API |
inputArguments() |
Defines the argument types passed directly to the paint() CSS function (optional) |
ctx is a PaintRenderingContext2D, which is almost identical to CanvasRenderingContext2D. However, pixel manipulation APIs like getImageData() and text rendering APIs are not supported. For shape-, path-, and gradient-focused drawing, you'll rarely run into limitations in practice.
Step 2: Register in Your Main Script
// main.js
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('my-painter.js');
}addModule() loads the module asynchronously. The background may briefly appear empty on initial render, so it's good practice to declare a fallback style alongside it.
Step 3: Call It in CSS
.card {
/* Fallback: for browsers that don't support the Paint API */
background-color: #6c63ff;
/* In supported browsers, the Worklet draws instead */
--my-color: #6c63ff;
--my-size: 30;
background-image: paint(my-painter);
}Progressive Enhancement principle: Using
if ('paintWorklet' in CSS)together with a CSS fallback declaration ensures basic styles work correctly in unsupported browsers. This pattern is the safest approach for production use.
Practical Examples
Example 1: Checkerboard Pattern Controlled via CSS Variables
This is the most intuitive example. You can create a fully dynamic checkerboard pattern with just three CSS variables.
At first glance, one thing might seem odd: why store a unitless number like --cell-size: 24 in a CSS variable? CSS custom properties store values as strings by default. Calling props.get('--cell-size').toString() returns the string "24", which you then convert to an integer with parseInt() for use in the loop. Storing 24px would require manually stripping the unit, which is more cumbersome. That's why using unitless numbers has become a common idiom.
Register with the same method described above (CSS.paintWorklet.addModule('checkerboard.js')), then use the code below.
// checkerboard.js
registerPaint('checkerboard', class {
static get inputProperties() {
return ['--cell-size', '--color-a', '--color-b'];
}
paint(ctx, { width, height }, props) {
const size = parseInt(props.get('--cell-size')) || 20;
const colorA = props.get('--color-a').toString() || '#ffffff';
const colorB = props.get('--color-b').toString() || '#000000';
for (let y = 0; y < height; y += size) {
for (let x = 0; x < width; x += size) {
const isEven = (Math.floor(x / size) + Math.floor(y / size)) % 2 === 0;
ctx.fillStyle = isEven ? colorA : colorB;
ctx.fillRect(x, y, size, size);
}
}
}
});.pattern-bg {
--cell-size: 24;
--color-a: #f0f4ff;
--color-b: #c7d2fe;
background-image: paint(checkerboard);
width: 100%;
height: 300px;
}
/* Dark mode switch — no image file replacement needed */
@media (prefers-color-scheme: dark) {
.pattern-bg {
--color-a: #1e1b4b;
--color-b: #312e81;
}
}| Point | Description |
|---|---|
geometry.width / height |
Receives the element's physical pixel size. The value passed already reflects devicePixelRatio, so it renders sharply on Retina displays without any extra correction |
props.get('--cell-size') |
Returns a CSS Typed OM object. Use after converting with parseInt() or .toString() |
| CSS variable change | Changing just --color-a immediately triggers a paint() re-invocation and re-renders |
Example 2: Ripple Animation Using @property + transition
Honestly, this pattern is the real highlight of the CSS Paint API. When you register a numeric custom property with @property, CSS transition automatically interpolates intermediate values. The Paint Worklet receives those intermediate values and re-renders each frame, producing a smooth effect without any JS animation loop.
Register with CSS.paintWorklet.addModule('ripple.js') as described earlier, then use the code below.
// ripple.js
registerPaint('ripple', class {
static get inputProperties() {
return ['--ripple-progress', '--ripple-color'];
}
paint(ctx, { width, height }, props) {
const progress = parseFloat(props.get('--ripple-progress')) || 0;
const color = props.get('--ripple-color').toString() || '#6c63ff';
const centerX = width / 2;
const centerY = height / 2;
// Maximum radius that reaches the element's corners in any direction
const maxRadius = Math.hypot(centerX, centerY);
const radius = maxRadius * progress;
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, width, height);
if (progress > 0) {
// save/restore safely isolates the globalAlpha state
ctx.save();
ctx.globalAlpha = 1 - progress;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
}
}
});/* Register a numeric type with @property — required for transition interpolation */
@property --ripple-progress {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
.ripple-btn {
--ripple-progress: 0;
--ripple-color: rgba(108, 99, 255, 0.4);
background-color: #f8fafc; /* fallback */
background-image: paint(ripple);
transition: --ripple-progress 0.6s ease-out;
}
.ripple-btn:hover {
--ripple-progress: 1;
}Without @property, CSS treats --ripple-progress as a plain string and no intermediate interpolation occurs. Declaring the <number> type with @property creates the 0 → 0.1 → 0.2 ... → 1 progression, which the Paint Worklet then receives and visualizes.
One more thing worth noting is the ctx.save()/ctx.restore() pattern. Directly mutating global Canvas state like globalAlpha and then manually restoring it becomes increasingly error-prone as code grows more complex. Getting into the habit of isolating state with save()/restore() — as Canvas API idiom dictates — is much safer.
Example 3: Skeleton Loading Shimmer Effect
This pattern drives --shimmer-progress with @keyframes, and for the same reason explained in the Ripple example, it requires an @property declaration. To directly manipulate a custom property value with @keyframes, CSS needs to know its type in order to interpolate.
Register with CSS.paintWorklet.addModule('skeleton-shimmer.js') as described earlier, then use the code below.
// skeleton-shimmer.js
registerPaint('skeleton-shimmer', class {
static get inputProperties() {
return ['--shimmer-progress'];
}
paint(ctx, { width, height }, props) {
const progress = parseFloat(props.get('--shimmer-progress')) || 0;
ctx.fillStyle = '#e2e8f0';
ctx.fillRect(0, 0, width, height);
const shimmerX = (progress * (width + 200)) - 100;
const gradient = ctx.createLinearGradient(shimmerX - 100, 0, shimmerX + 100, 0);
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.6)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
});/* @property declaration is required for the same reason as in the Ripple example */
@property --shimmer-progress {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
.skeleton {
--shimmer-progress: 0;
background-color: #e2e8f0; /* fallback */
background-image: paint(skeleton-shimmer);
border-radius: 8px;
height: 20px;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
from { --shimmer-progress: 0; }
to { --shimmer-progress: 1; }
}The shimmer effect that used to require a combination of linear-gradient, background-size, and background-position becomes far more intuitive code. The fact that it always renders perfectly regardless of element size is also a handy benefit in production.
Pros and Cons
Advantages
The two most distinctive advantages are resolution independence and the combination with @property. Because drawing is vector-based, it renders sharply on Retina and HiDPI displays without any extra handling. Combined with @property + transition/@keyframes, it enables pure CSS animations without JavaScript. These two points are the decisive reasons to use this API over image assets or JS Canvas.
| Item | Details |
|---|---|
| Resolution independence | Automatically renders sharply on Retina and HiDPI displays |
| Main thread design | Minimizes impact on UI responsiveness even when drawing complex patterns |
| CSS variable reactivity | paint() is only re-invoked when values in the inputProperties list change — no unnecessary re-renders |
| CSS animation integration | Smooth animations without JS using @property + transition/@keyframes |
| Reduced image assets | Completely replaces background PNG/SVG files with code — dark mode theme switching is solved by changing variable values |
| Reusability | A Worklet registered once can be reused on any element, just with different CSS variable values |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| No Firefox support | Not working as of 2025. With roughly 3–4% global market share, it's hard to ignore for consumer-facing services but may be acceptable for developer tools and internal dashboards | CSS fallback declaration + conditional if ('paintWorklet' in CSS) |
| Partial Safari support | Not fully supported; behavioral differences may occur | Verify behavior before deciding on scope of use |
| Canvas API subset | getImageData() and text rendering APIs are not supported |
Design around shapes, paths, and gradients |
| Polyfill limitations | GoogleChromeLabs' css-paint-polyfill exists but lacks pseudo-element support and is unstable in Firefox |
CSS fallback is often more stable than the polyfill |
| Separate file required | Worklets must be in a separate JS file | Use worklet-loader for Webpack or the ?url suffix for Vite |
| Debugging is awkward | Setting breakpoints inside a Worklet in DevTools is trickier than regular JS | Develop a habit of verifying via rendered output rather than console.log |
Most Common Mistakes in Production
-
Expecting transitions to work without
@property— I spent a long time debugging "why does nothing happen on hover?" the first time I tried the Ripple example myself, and this was the culprit. CSS custom properties are treated as strings by default, so no intermediate value interpolation occurs. If you need numeric animation, you must declare the type with@property. -
Forgetting to add tracked variables to
inputProperties— CSS variables not in this list will not triggerpaint()when changed. If you're wondering "I clearly changed the variable, why isn't the background updating?" — check this list first. -
The bundler inlining the Worklet file — Bundlers like Webpack and Vite inline or chunk-split JS files by default. Since Worklet files must be passed as a URL to
addModule(), they need special handling. In Vite, the patternimport workletUrl from './my-painter.js?url'→CSS.paintWorklet.addModule(workletUrl)is the clean solution.
Closing Thoughts
After working with this API, what struck me most is how much closer CSS and Canvas are than I expected. It feels strange at first that a single CSS property — background-image — can connect directly to a Canvas drawing context. But once you've used it, it feels like a natural extension that should have existed much sooner. The CSS animation pattern combined with @property is particularly one of the most talked-about use cases of 2024–2025. Given current browser support, I recommend getting hands-on experience in a side project or internal tool first.
Three steps to get started right now:
- Try pulling in a community-made Worklet from houdini.how. Many are published as npm packages, so you can get going with a single
addModule()call, and reading the source code will quickly make the structure click. - Hand-write a simple pattern like the checkerboard example yourself — the
registerPaint()→addModule()→ CSSpaint()flow will become second nature. Once you've gone through that flow once, more complex implementations will follow naturally. - Register a numeric variable with
@propertyand attach a CSS transition. Just having a value shift from 0 to 1 on hover unlocks a wide range of effects — Ripple, Shimmer, progress backgrounds — and once this pattern is in your toolkit, the possibilities expand considerably.
References
- CSS Painting API | MDN Web Docs
- Using the CSS Painting API | MDN Guide
- CSS Paint API | Chrome for Developers Blog
- CSS Painting API Level 1 | W3C Spec
- The CSS Paint API | CSS-Tricks
- Creating Generative Patterns with The CSS Paint API | CSS-Tricks
- Cross-browser paint worklets and Houdini.how | web.dev
- Drawing Graphics with the CSS Paint API | Codrops
- css-paint-polyfill | GoogleChromeLabs
- awesome-css-houdini | CSSHoudini
- Is Houdini Ready Yet?
- Programmatically create images with the CSS Paint API | SitePen