loke.dev
Header image for The Paint API Is an Engineering Escape Hatch

The Paint API Is an Engineering Escape Hatch

Move your most expensive visual patterns into the CSS Houdini pipeline to reclaim the main thread from image-heavy DOM nodes.

· 4 min read

I once spent three hours trying to optimize a hero section that felt like it was dragging a piano through mud. The culprit wasn't some massive JavaScript bundle or a memory leak; it was a stack of six nested div elements used just to create a "generative" background pattern. Every time the window resized, the browser gasped for air trying to recalculate the geometry.

That was the day I realized we often treat the DOM like a drawing canvas when it was never meant to be one. If you’re building complex visual patterns—think halftone grids, organic blobs, or skewed borders—by nesting HTML elements or layering 5MB background images, you're doing it the hard way.

The CSS Paint API (part of the Houdini umbrella) is your engineering escape hatch. It lets you write logic to draw directly into a CSS background, border, or mask, bypassing the DOM entirely.

The Problem: The DOM is Heavy

Every time you add a div to create a visual flourish, you’re adding an object the browser has to track. It has a position, a box model, event listeners, and accessibility roles. If you need 100 "dots" for a background pattern, 100 divs is a performance nightmare.

The Paint API moves this logic into a Worklet. It’s like a Web Worker but specifically for the rendering engine. It runs off the main thread, it’s stateless, and it’s incredibly fast.

Setting Up Your First Worklet

To use the Paint API, you need two things: a JavaScript file for the worklet and a CSS declaration.

First, let's create a simple "Checkerboard" worklet. We'll call this paint-worklet.js.

// paint-worklet.js
registerPaint('checkerboard', class {
  // This is where we tell the browser which CSS variables to watch
  static get inputProperties() {
    return ['--checker-size', '--checker-color'];
  }

  paint(ctx, geom, props) {
    const size = parseInt(props.get('--checker-size').toString()) || 20;
    const color = props.get('--checker-color').toString() || '#000';

    ctx.fillStyle = color;

    for (let y = 0; y < geom.height / size; y++) {
      for (let x = 0; x < geom.width / size; x++) {
        if ((x + y) % 2 === 0) {
          ctx.fillRect(x * size, y * size, size, size);
        }
      }
    }
  }
});

The paint function gives you a ctx (a 2D drawing context very similar to the <canvas> API), geom (the width and height of the element), and props (the CSS properties).

Connecting it to Your CSS

You can't just link the script and call it a day. You have to tell the browser to load the worklet via JavaScript, and then reference it in your CSS.

// In your main main.js or a script tag
if ('paintWorklet' in CSS) {
  CSS.paintWorklet.addModule('paint-worklet.js');
}

Now, your CSS becomes remarkably clean:

.hero-pattern {
  --checker-size: 40;
  --checker-color: #f06;
  
  /* The magic happens here */
  background-image: paint(checkerboard);
  
  width: 100%;
  height: 400px;
}

Why This is a Performance Win

When you use paint(checkerboard), the browser doesn't create any extra DOM nodes. It simply executes your drawing logic during its own paint phase.

If you change the --checker-size variable via JavaScript or a CSS transition, the worklet re-runs. Because the worklet is stateless and has no access to the DOM, the browser can optimize the hell out of it. It’s essentially a GPU-accelerated way to create custom images on the fly.

Real-World Use Case: The "Squircle"

Designers love squircles (that middle ground between a square and a circle, popularized by iOS icons). Creating them in CSS usually involves a hacky border-radius or a static SVG. With the Paint API, you can make it dynamic.

Imagine a button that morphs its "roundness" based on a data attribute.

// squircle-worklet.js
registerPaint('squircle', class {
  static get inputProperties() { return ['--squircle-radius']; }

  paint(ctx, geom, props) {
    const r = parseInt(props.get('--squircle-radius')) || 20;
    const w = geom.width;
    const h = geom.height;

    ctx.beginPath();
    ctx.moveTo(r, 0);
    ctx.lineTo(w - r, 0);
    ctx.quadraticCurveTo(w, 0, w, r);
    ctx.lineTo(w, h - r);
    ctx.quadraticCurveTo(w, h, w - r, h);
    ctx.lineTo(r, h);
    ctx.quadraticCurveTo(0, h, 0, h - r);
    ctx.lineTo(0, r);
    ctx.quadraticCurveTo(0, 0, r, 0);
    ctx.closePath();

    ctx.fillStyle = '#333';
    ctx.fill();
  }
});

In your CSS, you'd use it as a mask or a background:

.organic-button {
  --squircle-radius: 30;
  background: paint(squircle);
  mask-image: paint(squircle); /* Clip the content too! */
  padding: 1rem 2rem;
  border: none;
  color: white;
}

The "Gotchas" (Because there's always a catch)

The Paint API is brilliant, but it has boundaries you need to respect:

1. No External Assets: You can’t load images inside a Paint Worklet. You only get the 2D context. If you want to manipulate an image, you have to pass it as a CSSImageValue through a property.
2. HTTPS Only: Like most modern web APIs, this won't work over an insecure connection (except for localhost).
3. No Text (Yet): In the current spec implementation in most browsers, fillText isn't supported in Paint Worklets. If you need text, you're back to the DOM.
4. Browser Support: It's a Chromium-heavy feature right now (Chrome, Edge, Opera). Safari has some support behind flags, and Firefox is still "considering" it. Always provide a fallback background color or image.

When to Reach for the Escape Hatch

Don't rewrite your whole UI in Houdini. Use it when you find yourself reaching for:
- Large, repetitive SVG backgrounds that cause lag on scroll.
- Complex CSS "art" that requires more than two pseudo-elements.
- Dynamic visual effects that change based on user input (like a mouse-tracking gradient).

The Paint API isn't just about making things pretty; it's about making things cheap. Reclaiming the main thread is one of the best gifts you can give your users, especially those on low-powered mobile devices. Stop building houses out of divs when you could just paint them.