Building a custom paint worklet for a reusable background pattern
A paint worklet does not draw a background as a finished image file, it draws it as a small JavaScript program that the browser runs directly into the element's background on every repaint. Register a parametrized pattern as a worklet once, and it can be reused across any number of components through CSS custom properties, without exporting a new SVG file for every color variant.
Table of Contents
- 1. What the CSS Paint API is and how it plugs into the cascade
- 2. Registering a first worklet: registerPaint and addModule
- 3. Wiring the worklet into CSS: paint() as a background value
- 4. Passing values to the worklet: inputProperties and @property
- 5. Reusability: parametrizing one pattern for many components
- 6. Performance comparison: paint worklet vs. SVG background vs. image file
- 7. When a paint worklet is not worth it
- 8. Browser support and a robust fallback strategy
- 9. Debugging and development workflow for paint worklets
- 10. Summary
- 11. FAQ
1. What the CSS Paint API is and how it plugs into the cascade
The CSS Paint API is part of the Houdini initiative and lets you register a JavaScript function as a valid value for background-image and similar image properties. Instead of a URL or a gradient, the value becomes paint(pattern-name), and the browser invokes the registered paint() method of the worklet on every layout or style change, drawing straight into the element's background on a canvas-like surface.
The key difference from a classic background: url(...) is that a paint worklet loads no finished file at all, it recomputes on every call, based on the element's current size and the custom properties passed in. That makes worklets especially interesting for patterns that need to adapt responsively to the box size, for example a dot grid that gets redrawn crisply on every breakpoint change instead of blurring like a scaled bitmap.
2. Registering a first worklet: registerPaint and addModule
A paint worklet lives in its own JavaScript file, executed in a separate thread with no DOM access at all. Inside that file you call registerPaint(name, klasse), passing a class with a paint(ctx, size, properties) method that draws on the supplied 2D context. The main document loads that module asynchronously through CSS.paintWorklet.addModule(path), before the first paint call in the stylesheet can actually take effect.
It matters that addModule returns a promise and the browser only draws the pattern once the module has loaded. On a very early first render, the background can therefore briefly stay empty. In practice you register the worklet as early as possible in the document head and plan for an unobtrusive CSS fallback background that holds while the worklet is not ready yet.
// dot-pattern-worklet.js -- registered as a CSS Paint Worklet
class DotPatternPainter {
static get inputProperties() {
return ['--dot-color', '--dot-size', '--dot-gap'];
}
paint(ctx, size, properties) {
const color = properties.get('--dot-color').toString().trim() || '#7c3aed';
const dotSize = parseFloat(properties.get('--dot-size')) || 2;
const gap = parseFloat(properties.get('--dot-gap')) || 18;
ctx.fillStyle = color;
for (let y = gap / 2; y < size.height; y += gap) {
for (let x = gap / 2; x < size.width; x += gap) {
ctx.beginPath();
ctx.arc(x, y, dotSize, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
registerPaint('dot-pattern', DotPatternPainter);
3. Wiring the worklet into CSS: paint() as a background value
Once the module has loaded, paint(dot-pattern) behaves in CSS like any other background-image value: it combines with background-size, multiple comma-separated layers, and even background-blend-mode. For a product card, a single CSS block is now enough to set a dot grid as a decorative background, with no extra HTTP request for an image file at all.
Loading the module happens in the main document with a few lines of JavaScript and only needs to run once per page, regardless of how many elements later use the pattern. That one-time setup is one of the practical advantages over several individual SVG files, each of which would need to be loaded and cached by the browser on its own.
.product-card {
--dot-color: #7c3aed;
--dot-size: 1.5px;
--dot-gap: 16px;
background-image: paint(dot-pattern);
background-color: #faf5ff;
}
.product-card--dark {
--dot-color: #c4b5fd;
background-color: #1e1033;
}
4. Passing values to the worklet: inputProperties and @property
A worklet only receives values from CSS through the custom properties listed in inputProperties, every other CSS property on the element stays invisible to it. That means the pattern's color, dot size, and gap have to be defined as their own custom properties and registered in the painter class's static getter list, otherwise properties.get(...) inside the worklet simply returns an empty value.
Registering those custom properties with @property on top guarantees the worklet gets typed, parsed values instead of raw strings, and lets the browser animate between two values, for example smoothly widening the dot gap on hover. Without registration, every custom property stays a plain text token that the worklet has to parse itself, which only surfaces unit typos at runtime.
@property --dot-gap {
syntax: '<length>';
inherits: false;
initial-value: 16px;
}
.product-card:hover {
--dot-gap: 24px; /* animatable because --dot-gap is typed as <length> */
transition: --dot-gap 0.4s ease;
}
5. Reusability: parametrizing one pattern for many components
The real payoff of a paint worklet shows up once a single registered pattern gets used across an entire design system, for cards, hero sections, and badges at the same time, each with different custom property values for color and density. Instead of maintaining five separate SVG files for five color variants, you maintain one JavaScript file and vary the result entirely through CSS, which simplifies maintenance considerably once the brand color changes.
This parametrization also works across media queries and container queries, because the worklet gets called again on every relevant style change. A dot grid can automatically tighten on small viewports simply by overriding --dot-gap inside a media query, with no second, mobile-optimized image file to ship at all.
6. Performance comparison: paint worklet vs. SVG background vs. image file
A static SVG or PNG background gets decoded once by the browser and drawn from the image cache afterward, which is extremely cheap per frame, but treats every size change as scaling an already-finished bitmap. A paint worklet, on the other hand, redraws on every repaint, which is barely measurable for a simple dot grid on modern devices, but causes real repaint cost for complex, computation-heavy patterns with many paths, visible in the performance panel's profiling data.
The advantage does not lie in raw drawing speed, it lies in the worklet loading no extra file over the network and producing any number of color variants without a new request, while the same flexibility with SVG would need either multiple files or a JavaScript-generated data URI. For rare repaints, say a static card background, an optimized SVG often remains the cheaper choice, while heavily parametrized, frequently changing patterns usually favor the worklet in practice.
7. When a paint worklet is not worth it
For simple geometric patterns that CSS gradients alone can express, stripes, checkerboards, or soft color transitions, a paint worklet is pure overhead: a repeating-linear-gradient achieves the same visual result without an extra JavaScript file, without the loading time for addModule, and with full support even in Safari, where paint worklets remain unimplemented to this day.
Even when a pattern only appears once on the entire page and never changes, the overhead of registration and module loading outweighs the benefit compared to a single, cleanly optimized SVG file. A paint worklet earns its keep where real parametrizability across many components is needed, not as a replacement for every decorative background image.
8. Browser support and a robust fallback strategy
Chrome and Edge have fully supported the CSS Paint API for years, Firefox only ships it behind an experimental flag, and Safari has so far implemented none of the Houdini paint building blocks. That makes the API a clear progressive-enhancement feature for production projects: it must never be the only source for a content-relevant background, only ever an improvement layered on top of a working CSS fallback.
The cleanest feature detection uses @supports (background-image: paint(x)), because it checks the browser's actual capability instead of relying on a JavaScript check of CSS.paintWorklet, which can itself throw in older browsers. Inside the @supports block you optionally load the worklet module through JavaScript, outside of it a plain gradient or a static image remains as a solid base.
.product-card {
/* Fallback for every browser without CSS Paint API support */
background-image: radial-gradient(circle, #c4b5fd 1.5px, transparent 1.5px);
background-size: 16px 16px;
}
@supports (background-image: paint(dot-pattern)) {
.product-card {
background-image: paint(dot-pattern);
}
}
9. Debugging and development workflow for paint worklets
Errors inside a worklet do not automatically show up in the main document's regular console, because the code runs in its own, isolated worklet context. In Chrome DevTools that context can be selected explicitly through the dropdown menu at the top of the console ("Paint Worklet"), to see log output and exceptions from paint() directly instead of guessing in the dark why a background simply stays empty.
A typical debugging mistake is changing a custom property without listing it in inputProperties: the worklet then silently receives no updated value, creating the impression that CSS changes are being ignored. Anyone introducing a new parameter should always extend the static getter list first and only then use the matching CSS custom property in the stylesheet.
| Approach | Network request | Responsive sharpness | Parametrizability |
|---|---|---|---|
paint() worklet |
One module, once per page | Always sharp, recomputed | Any number of variants via custom properties |
| SVG background | One file per variant | Scalable, but static | Only through multiple files or a data URI |
| PNG/JPEG background | One file per variant | Loses sharpness when scaled | None, fixed pixel values |
| CSS gradient | No request | Always sharp | Possible via custom properties, but geometrically limited |
Mironsoft
Modern CSS, layout architecture and rendering performance
CSS that stays maintainable instead of breaking with every change?
We review existing stylesheets for specificity chaos and layout thrashing, then build a CSS architecture with cascade layers, custom properties and modern layout primitives that still makes sense after the tenth feature.
CSS Audit
Systematically uncovering specificity issues, cascade conflicts and unused selectors.
Architecture Refactoring
Introducing cascade layers, custom properties and design tokens cleanly.
Performance Tuning
Fixing layout thrashing, expensive selectors and rendering bottlenecks.
10. Summary
CSS Paint Worklets: The Essentials at a Glance
Core idea
A paint worklet registers a JavaScript class with a paint() method that draws directly into the background on every repaint.
Passing values
inputProperties lists the custom properties the worklet is allowed to read. @property makes them typed and animatable.
Performance
No extra network request per variant, but real computation cost on every repaint for complex patterns.
Fallback requirement
Check @supports (background-image: paint(x)) and always keep a CSS fallback ready for Safari and older Firefox versions.