EmpireUI
Get Pro
← Blog8 min read#houdini#paint worklet#css

CSS Houdini Paint Worklet: Custom CSS Properties With GPU Power

CSS Houdini Paint Worklets let you write GPU-accelerated custom paint logic that hooks directly into the browser's render pipeline — here's how to actually use them.

Abstract geometric GPU shader pattern on dark background

What Houdini Actually Is (And Why It Took So Long)

The CSS Houdini project started getting real browser traction around 2018, but Paint Worklets only landed in Chrome 65 and didn't hit Firefox stable until much later. The core idea is simple: instead of waiting years for a CSS feature to land in spec, Houdini gives you hooks into the browser's rendering engine so you can build your own CSS properties, layouts, and paint operations. You're not faking it with JavaScript DOM manipulation after the fact — you're wiring in at the pixel level.

There are actually several Houdini APIs: the CSS Properties and Values API, Layout API, Animation Worklet, and the one we care about here — the Paint API. Each targets a different phase of the rendering pipeline. Paint Worklets plug into the 'paint' phase, right where the browser figures out what pixels to draw inside a box. That means your custom logic runs off the main thread, exactly like native CSS does. No layout thrashing, no forced reflows.

Honestly, most developers ignore Houdini entirely because the docs are scattered across MDN, a Google Developers archive, and a half-dozen blog posts from 2019. That's a shame, because Paint Worklets are the single most practical piece of the whole Houdini spec. You can ship real effects with them today — animated gradients, noise textures, geometric patterns — all via a single background: paint(my-painter) declaration.

Quick aside: if you've been building visual effects with canvas animations in React, Houdini is the next natural step. You get similar drawing primitives — the worklet context exposes a Canvas 2D-like API — but the browser owns the lifecycle, not your JavaScript.

Registering a Paint Worklet: The Minimal Setup

There are exactly two pieces: the worklet file itself, and the registration call in your main JavaScript. Start with the worklet. Create a file — call it noise-painter.js — and define your painter class inside it.

// noise-painter.js
class NoisePainter {
  static get inputProperties() {
    return ['--noise-color', '--noise-scale'];
  }

  paint(ctx, geometry, properties) {
    const color = properties.get('--noise-color').toString().trim() || '#6366f1';
    const scale = parseFloat(properties.get('--noise-scale')) || 4;
    const { width, height } = geometry;

    for (let x = 0; x < width; x += scale) {
      for (let y = 0; y < height; y += scale) {
        const alpha = Math.random();
        ctx.fillStyle = color;
        ctx.globalAlpha = alpha * 0.15;
        ctx.fillRect(x, y, scale, scale);
      }
    }
  }
}

registerPaint('noise-painter', NoisePainter);

Then in your main bundle, you register it. One line. That's it.

if ('paintWorklet' in CSS) {
  CSS.paintWorklet.addModule('/noise-painter.js');
}

Now you can use it anywhere in CSS with custom properties driving the behavior — which is the whole point. Your design system speaks CSS; the heavy lifting happens off-thread.

.hero-card {
  --noise-color: #818cf8;
  --noise-scale: 3;
  background: paint(noise-painter);
  border-radius: 12px;
  padding: 24px;
}

Worth noting: the worklet file must be a separate JavaScript file served from the same origin. You can't inline it as a blob URL in Firefox, and bundlers don't handle it automatically — you need to copy it to your public directory or configure your bundler to treat it as a static asset.

Registering Custom Properties With CSS.registerProperty

Here's where it gets interesting. Custom properties declared with -- are just strings — the browser doesn't know they're colors or numbers, so it can't animate them. CSS.registerProperty fixes that. You give the browser a type, an initial value, and whether the property inherits, and suddenly you can transition and animate it natively.

CSS.registerProperty({
  name: '--noise-scale',
  syntax: '<number>',
  inherits: false,
  initialValue: '4',
});

CSS.registerProperty({
  name: '--noise-color',
  syntax: '<color>',
  inherits: true,
  initialValue: '#6366f1',
});

Once registered, you can animate --noise-scale with a CSS transition and the worklet will repaint at each frame as the value changes. That's basically free — no requestAnimationFrame, no JS event listeners, no manual DOM updates. The browser drives the animation and calls your paint() at 60fps (or 120fps if the user has a high-refresh display).

In practice, this is where Houdini becomes genuinely useful for UI work. An animated gradient border that respects your design tokens? A hover effect that changes a custom property and triggers a repaint? These are 10-line CSS animations, not bespoke JavaScript. If you're already thinking in CSS custom properties and design systems, this is a natural extension of the same mental model.

One more thing — the @property at-rule does the same thing as CSS.registerProperty without the JS. Chrome 85+ and Firefox 128+ both support it. Prefer it in CSS-first codebases: ``css @property --noise-scale { syntax: '<number>'; inherits: false; initial-value: 4; } ``

Building an Animated Aurora Gradient Worklet

Let's do something you'd actually ship. The aurora-style animated gradient is everywhere in modern UI — you can see how Empire UI approaches it in the aurora style hub. With Houdini you can build a version that animates purely through CSS custom property transitions, with zero layout cost.

// aurora-painter.js
class AuroraPainter {
  static get inputProperties() {
    return [
      '--aurora-hue',
      '--aurora-spread',
      '--aurora-opacity',
    ];
  }

  paint(ctx, geo, props) {
    const hue = parseFloat(props.get('--aurora-hue')) || 220;
    const spread = parseFloat(props.get('--aurora-spread')) || 0.6;
    const opacity = parseFloat(props.get('--aurora-opacity')) || 0.8;
    const { width, height } = geo;

    ctx.clearRect(0, 0, width, height);

    const grad = ctx.createRadialGradient(
      width * 0.5,
      height * spread,
      0,
      width * 0.5,
      height * 0.5,
      Math.max(width, height) * 0.9
    );

    grad.addColorStop(0, `hsla(${hue}, 80%, 60%, ${opacity})`);
    grad.addColorStop(0.4, `hsla(${hue + 60}, 70%, 45%, ${opacity * 0.6})`);
    grad.addColorStop(1, `hsla(${hue + 120}, 60%, 30%, 0)`);

    ctx.fillStyle = grad;
    ctx.fillRect(0, 0, width, height);
  }
}

registerPaint('aurora-painter', AuroraPainter);

Register it, then animate with a simple keyframe:

@property --aurora-hue {
  syntax: '<number>';
  inherits: false;
  initial-value: 220;
}

.aurora-card {
  --aurora-hue: 220;
  --aurora-spread: 0.5;
  --aurora-opacity: 0.75;
  background: paint(aurora-painter);
  animation: aurora-shift 8s ease-in-out infinite alternate;
}

@keyframes aurora-shift {
  to { --aurora-hue: 340; }
}

That's a smooth, GPU-accelerated hue rotation on a custom paint, driven entirely by CSS. The browser interpolates the registered <number> property and calls your worklet at each frame. Compare this to the alternative — a requestAnimationFrame loop that touches background-image on a DOM node every 16ms. The Houdini version doesn't block anything.

Look, the effect isn't magic. You're still running JavaScript code. But you're running it in a worklet thread with a scoped execution context, no access to the DOM, and a tight API surface the browser can reason about statically. That's what gives it the performance profile closer to CSS than to JS-driven animation.

Performance: What You Actually Get

Paint Worklets don't automatically run on the GPU. The confusion here is understandable. What they do is run off the main thread — in a dedicated worklet thread with a rendering context that the compositor can schedule more freely. GPU compositing still depends on what you paint into. If your worklet produces a result that the compositor can cache and transform (e.g., a static texture that only changes when a custom property changes), you get GPU compositing for free on top of the off-thread painting.

Practically speaking: for effects that change only when CSS properties change (hover states, CSS animations), Paint Worklets are extremely efficient. For effects that need to update every frame with random or time-based values — like the noise painter above with Math.random() — you'll trigger a repaint every frame, which means your optimization gain is mostly about staying off the main thread, not about avoiding repaints entirely. You'd want to use CSS Animation Worklet for true frame-independent animation.

For comparison, a naive CSS background-image: url('data:image/svg+xml,...') approach that regenerates SVG on the fly from JavaScript is far worse — that's synchronous string concatenation on the main thread, followed by a style recalculation. Paint Worklets avoid both. That matters on a UI that has 40+ cards on screen — imagine what box shadow stacking does at scale, then multiply it by a background paint.

Worth noting: as of 2026, Safari still has partial support for Houdini. The Paint API (CSS.paintWorklet) works in Safari 16.4+, but CSS.registerProperty has been available since Safari 16.4 as well. You're safe in modern Chrome, Edge, and recent Safari. Firefox 128+ covers the rest. Still, always wrap in the feature check — if ('paintWorklet' in CSS) — and provide a fallback background.

Using Houdini in a React or Next.js Project

The integration story is simpler than you'd expect. Copy your worklet files into /public so Next.js serves them as static assets. Register them client-side in a useEffect, and you're done. The tricky part is SSR — the CSS object doesn't exist on the server, so you need to guard the registration.

// components/HoudiniLoader.tsx
'use client';
import { useEffect } from 'react';

export function HoudiniLoader() {
  useEffect(() => {
    if (typeof CSS !== 'undefined' && 'paintWorklet' in CSS) {
      (CSS as any).paintWorklet.addModule('/aurora-painter.js');
    }
  }, []);

  return null;
}

Drop <HoudiniLoader /> in your root layout. It mounts once, registers the worklet, and then every component in your app can use background: paint(aurora-painter) in its CSS. There's no context, no provider, no hook needed at the component level — just CSS.

For TypeScript users: the CSS.paintWorklet type isn't in lib.dom.d.ts yet (as of TypeScript 5.4). Cast with (CSS as any).paintWorklet or extend the CSS interface in a .d.ts file in your project. It's a minor papercut.

One pattern worth adopting: co-locate your worklet file with your component, then copy it to public via a build step. The glassmorphism generator shows how parameterized CSS effects compose cleanly — the same principle applies here. Your worklet encapsulates the drawing logic; CSS custom properties are the public API.

Debugging and Gotchas You'll Actually Hit

The worklet runs in an isolated global context. There's no console.log output visible in DevTools unless you're on Chrome 102+, which added worklet logging to the 'Worklets' panel. Earlier, you were debugging blind. That's changed, but the Worklets panel is hidden — open DevTools, go to the three-dot menu, then 'More tools', then 'JavaScript Profiler' and look for Worklets there. Or just use postMessage to relay debug info back to the main thread during development.

The paint() function is called synchronously during paint. Don't do anything async inside it — no fetch, no promises, no timers. If you need external data, pass it in through custom properties as encoded strings. For complex data, a common pattern is to JSON-encode a value into a custom property string and parse it in the worklet, though that's obviously expensive for large payloads.

// Encoding data as a CSS custom property (main thread)
document.documentElement.style.setProperty(
  '--chart-data',
  JSON.stringify([0.2, 0.8, 0.5, 0.9, 0.3])
);

// Decoding in the worklet
paint(ctx, geo, props) {
  const raw = props.get('--chart-data').toString();
  const values = JSON.parse(raw);
  // draw a mini sparkline...
}

One gotcha: if your worklet file fails to load (404, parse error, CORS), background: paint(...) silently renders as transparent. No error in the console by default. Always check the Network tab to confirm the worklet file loads cleanly. And if you're using a Content Security Policy, you'll need to add 'self' or the specific path to your script-src-elem directive — worklet scripts count as scripts.

That said, once you've got the setup working, the developer experience is surprisingly clean. You write drawing code that looks like canvas work, wire it up with CSS custom properties that look like color system tokens, and get effects that would otherwise need WebGL. That's a good trade.

FAQ

Does CSS Houdini Paint Worklet work in all browsers?

Chrome 65+, Edge 79+, and Safari 16.4+ all support it. Firefox shipped support in version 128. Always add if ('paintWorklet' in CSS) as a guard and provide a CSS fallback — don't assume support.

Can I use CSS.registerProperty to animate custom properties?

Yes, and that's one of the best reasons to use it. Register a <number> or <color> property, then animate it with a CSS keyframe or transition — the browser interpolates it natively and triggers worklet repaints at each frame.

Do Paint Worklets actually use the GPU?

They run off the main thread in a dedicated worklet thread, not the GPU directly. GPU compositing still happens if the browser can cache and composite the painted result. The performance win is mainly about not blocking the main thread.

How do I use a Paint Worklet in Next.js or a typical React app?

Put the worklet file in your /public directory and register it client-side with CSS.paintWorklet.addModule('/your-worklet.js') inside a useEffect. Guard against SSR with a typeof CSS !== 'undefined' check.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Houdini Paint Worklet: Custom Backgrounds No One Else HasGenerative Art With CSS: Randomness, Houdini and CSS Paint APICSS Animation Performance: GPU Compositing, will-change, Layout ThrashingCSS Custom Properties as a Design System: The Right Architecture