CSS Paint API: Building a Custom Paint Worklet for Background Patterns
AI generated
{ }
@
CSS · Houdini · Paint API · Performance
The CSS Paint API in Practice
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.

15 min read registerPaint · paint() · @property Chrome · Edge · Firefox (flag) · Safari (unsupported)

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.

11. FAQ: CSS Paint Worklets: The Essentials at a Glance

1What is the CSS Paint API in the first place?
Part of the Houdini initiative, letting you register a JavaScript function as a value for background-image. The browser calls that function on every repaint and draws directly into the background.
2How do I register a paint worklet?
Define a class with paint(ctx, size, properties) using registerPaint(name, klasse) in its own JavaScript file, then load that file in the main document with CSS.paintWorklet.addModule(path).
3How do CSS values get into the worklet?
Only through custom properties listed in the painter class's static inputProperties list. Every other CSS property on the element stays invisible to the worklet.
4What does @property add when combined with a worklet?
@property gives the custom property a fixed type and an initial value, so the worklet is guaranteed parsed values and the browser can animate between two values.
5Is a paint worklet faster than an SVG background?
Not inherently. An SVG is decoded once and drawn cheaply from cache afterward, a worklet recomputes on every repaint. The advantage lies in parametrizability without extra requests, not raw drawing speed.
6Which browsers support the CSS Paint API?
Chrome and Edge fully, Firefox only behind an experimental flag, Safari not at all so far. That makes the API a progressive-enhancement feature.
7How do I build a clean fallback?
Check with @supports (background-image: paint(name)) whether the browser supports the API, and define a gradient or static image as the base outside that block.
8Why does my background sometimes stay empty briefly?
Because CSS.paintWorklet.addModule returns a promise and the pattern is only drawn after the module has loaded. A CSS fallback background bridges that short gap.
9Can I debug a worklet in DevTools?
Yes, Chrome DevTools lets you select the worklet context in the console dropdown to see log output and errors directly from the paint() method.
10When should I stick with SVG or gradients instead?
When the pattern is simple, only changes once, or can be expressed with repeating-linear-gradient. A worklet only pays off with real parametrizability across many components.