All Posts
CSSHoudiniBrowser InternalsAnimationCreative Coding

CSS Houdini Paint API: Drawing Anything Directly in the Browser's Render Pipeline

CSS Houdini's Paint API lets you register JavaScript worklets that the browser calls during the paint phase — giving you a canvas-like drawing surface for any CSS property. Here is how it works and why it matters.

CSS Houdini Paint API: Drawing Anything Directly in the Browser's Render Pipeline

For most of CSS's history, if you wanted a visual effect the spec did not cover, you reached for JavaScript to manipulate the DOM, sprinkled on some canvas trickery, or just exported a PNG from Figma and called it a day. All of those approaches share the same weakness: they sit outside the browser's rendering engine, fighting against it instead of working with it.

CSS Houdini is a collection of low-level browser APIs that rip open the rendering pipeline and let you participate in it directly. The crown jewel for visual effects is the Paint API (also called CSS Painting API Level 1). It lets you write a JavaScript worklet that the browser calls at paint time — with a Canvas2D-like context — whenever it needs to fill a background-image, border-image, or mask-image.

The result: infinitely customisable CSS values that respond to custom properties, animate on the GPU, and cost exactly zero DOM nodes.


How the Browser Renders Things (The Bit You Need to Know)

The browser's rendering pipeline looks roughly like this:

 Parse HTML/CSS
      ↓
  Style (CSSOM)
      ↓
  Layout (reflow)
      ↓
  Paint  ←── CSS Paint Worklet hooks in here
      ↓
  Composite (GPU)

Historically, the only way to affect the Paint step was to write valid CSS. Houdini punches a hole at exactly that step and lets your JavaScript run — synchronously, in a separate thread, with zero DOM access — to produce pixels.

Because the worklet runs off the main thread and produces a texture the compositor can cache, this is dramatically more efficient than requestAnimationFrame + canvas overlay hacks.


Your First Paint Worklet: Diagonal Stripes

Let's build something concrete. We want a CSS utility class that renders a diagonal stripe pattern, with configurable stripe width and color, usable directly in CSS.

Step 1 — Write the Worklet

// stripe-painter.js  (loaded as a PaintWorklet, NOT a regular script)

registerPaint("diagonal-stripes", class {
  // Declare which CSS custom properties this painter reads
  static get inputProperties() {
    return [
      "--stripe-color",
      "--stripe-width",
      "--stripe-gap",
    ];
  }

  paint(ctx, geometry, properties) {
    const color = properties.get("--stripe-color").toString().trim() || "#6366f1";
    const width = parseFloat(properties.get("--stripe-width")) || 4;
    const gap   = parseFloat(properties.get("--stripe-gap"))   || 8;

    const step = width + gap;
    const { width: w, height: h } = geometry;

    ctx.strokeStyle = color;
    ctx.lineWidth   = width;

    // Draw diagonal lines across the bounding box
    for (let x = -h; x < w + h; x += step) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x + h, h);
      ctx.stroke();
    }
  }
});

Step 2 — Register the Worklet

// main.js — runs on the main thread
if ("paintWorklet" in CSS) {
  CSS.paintWorklet.addModule("/stripe-painter.js");
}

Step 3 — Use It in CSS

.card {
  --stripe-color: #6366f1;
  --stripe-width: 3px;
  --stripe-gap: 10px;

  background-image: paint(diagonal-stripes);
  border-radius: 12px;
  padding: 2rem;
}

That is it. The paint(diagonal-stripes) value is valid CSS. The browser asks your worklet to produce an image whenever it needs to paint .card's background, passing the current values of the declared custom properties. Change --stripe-color via JavaScript and the browser re-paints — no JS animation loop required.

Diagonal stripe pattern rendered with the CSS Paint API Custom geometric patterns that would require a canvas overlay or a generated PNG can be expressed as a single CSS value.


The Magic: Animating with CSS Custom Properties

Because the worklet declares inputProperties, the browser knows to call paint() again whenever those properties change. Combined with CSS transitions or the Web Animations API, you get hardware-accelerated animation of completely arbitrary visual effects.

.card {
  --stripe-gap: 10px;
  transition: --stripe-gap 0.4s ease;
}

.card:hover {
  --stripe-gap: 20px;
}

When you hover the card, the browser smoothly interpolates --stripe-gap from 10px to 20px and calls your worklet on every intermediate frame — all without a single requestAnimationFrame call in your code.

This works because CSS custom property transitions run on the compositor thread where the Paint worklet also lives. The main thread is completely uninvolved. Jank-free, even during heavy JS work on the main thread.


A More Complex Example: Wavy Borders

A classic CSS pain point: there is no built-in border-style: wavy that looks good. With the Paint API this is a few dozen lines.

registerPaint("wavy-border", class {
  static get inputProperties() {
    return ["--wave-color", "--wave-height", "--wave-frequency", "--border-thickness"];
  }

  paint(ctx, { width, height }, props) {
    const color     = props.get("--wave-color").toString().trim()  || "#ec4899";
    const amplitude = parseFloat(props.get("--wave-height"))       || 6;
    const frequency = parseFloat(props.get("--wave-frequency"))    || 0.05;
    const thickness = parseFloat(props.get("--border-thickness"))  || 2;

    ctx.strokeStyle = color;
    ctx.lineWidth   = thickness;

    // Bottom wavy border
    ctx.beginPath();
    for (let x = 0; x <= width; x++) {
      const y = height - thickness - amplitude
              + Math.sin(x * frequency * Math.PI) * amplitude;
      x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.stroke();
  }
});
.section-heading {
  --wave-color: #ec4899;
  --wave-height: 5;
  --wave-frequency: 0.03;
  --border-thickness: 2;

  border-bottom: 14px solid transparent; /* reserve space */
  background-image: paint(wavy-border);
  background-repeat: no-repeat;
  background-position: bottom;
  padding-bottom: 0.5rem;
}

No SVG. No border-image trickery. No pseudo-element with a background gradient. Just CSS.


Understanding the Worklet Execution Model

The Paint worklet intentionally has a stripped-down, sandboxed API surface. Understanding why helps you avoid common mistakes.

What You Have Access To

AvailableNot Available
Canvas 2D context (ctx)DOM
Element's width / heightwindow / document
Declared CSS custom propertiesfetch / XHR
Math, Date, basic JSlocalStorage

No DOM access is not a limitation — it is the point. The worklet runs on a different thread (the "paint thread") and may be called concurrently for multiple elements. DOM access would require synchronisation that would kill performance.

Statelessness

Each call to paint() should be pure and stateless. The browser may call it at any time, from any thread, in any order. If you need to animate a value over time (e.g., a loading shimmer), the correct approach is to animate a CSS custom property externally (via CSS transitions or the Web Animations API) and let the worklet simply read the current value.

// ❌ Wrong — storing state in the worklet
class ShimmerPainter {
  constructor() {
    this.offset = 0; // This state is unreliable
  }
  paint(ctx, geom) {
    this.offset += 2; // Do not do this
    // ...
  }
}

// ✅ Correct — read state from a CSS property driven externally
class ShimmerPainter {
  static get inputProperties() { return ["--shimmer-offset"]; }
  paint(ctx, geom, props) {
    const offset = parseFloat(props.get("--shimmer-offset")) || 0;
    // Draw based on offset, which CSS animation controls
  }
}

Browser Support and the Polyfill Story

The Paint API is supported in Chromium-based browsers (Chrome, Edge, Opera, Samsung Internet). Firefox and Safari are not there yet — both cite concerns around the worklet threading model. Check caniuse.com/css-paint-api for the latest status.

Browser compatibility overview for CSS Houdini features Cross-browser testing remains essential — Houdini APIs have uneven support across the major engines.

For production use, the recommended approach is progressive enhancement:

// Register worklet only in supporting browsers
if ("paintWorklet" in CSS) {
  CSS.paintWorklet.addModule("/my-painter.js");
  document.documentElement.classList.add("houdini");
}
/* Fallback for non-supporting browsers */
.card {
  background-color: #6366f1;
}

/* Enhanced version */
.houdini .card {
  background-color: transparent;
  background-image: paint(diagonal-stripes);
}

There is also css-paint-polyfill by the Chrome team, which shims the API for older browsers using a main-thread canvas fallback. It loses the threading benefits but keeps the API surface identical.


Real-World Use Cases

1. Skeleton Loading Screens

Replace static gray bars with an animated shimmer painted entirely in CSS — no JS loops, no extra DOM nodes.

2. Generative Backgrounds

Data-driven, procedurally generated backgrounds for marketing cards, hero sections, or data visualisations. Pass a --seed custom property and generate unique patterns per element.

3. Custom Progress / Gauge Components

A circular gauge or radial progress bar that is purely CSS-driven — no SVG, no canvas element, just background-image: paint(radial-gauge) and --progress: 0.73.

4. Advanced Border Effects

Dashed borders with custom dash patterns, borders that follow Bézier curves, multi-color gradient borders — all things CSS cannot express natively but your worklet can paint in 30 lines.


The Bigger Picture: Houdini's Other APIs

The Paint API is one piece of the Houdini puzzle. The full suite includes:

  • CSS Properties and Values API (CSS.registerProperty) — strongly typed custom properties with animation support
  • Layout API — custom layout algorithms (like a masonry grid) as a worklet
  • Animation Worklet — animation logic tied to scroll position or time, running off-main-thread
  • CSS Typed OM — a typed, performant alternative to string manipulation in element.style

Together, they represent a fundamental shift: from CSS as a closed spec you wait years for browsers to implement, to CSS as an extensible platform where you write the missing primitives yourself.


Conclusion

The CSS Paint API is genuinely one of the most underused browser features available today. It closes the gap between "what CSS supports" and "what designers want" by letting you fill that gap yourself — in a way that integrates natively with the browser's rendering pipeline, animates efficiently, and degrades gracefully.

If you spend any time doing creative front-end work, building design systems, or performance-tuning animation-heavy UIs, this API deserves a place in your toolkit. The learning curve is shallow, the performance ceiling is high, and the creative possibilities are, quite literally, anything you can draw.

Start with a simple pattern, hook up a CSS custom property, and watch your CSS do things it was never supposed to be able to do.