EmpireUI
Get Pro
← Blog9 min read#generative art#css#houdini

Generative Art With CSS: Randomness, Houdini and CSS Paint API

Learn how to create generative art purely in CSS using the Houdini Paint API, custom properties, and pseudo-randomness — no canvas, no JS required.

Colorful generative art abstract patterns rendered in a browser window

Why CSS Can Do Generative Art Now

For years, generative art on the web meant Canvas 2D or WebGL. CSS was decorative wallpaper — beautiful, but static. That changed when Houdini shipped. The CSS Paint API (part of the Houdini worklet spec, available in Chromium since Chrome 65 in 2018) lets you write a JavaScript class that paints into a <canvas>-like context and then use it exactly like a CSS background-image. No DOM manipulation. No requestAnimationFrame loop in your main thread.

Honestly, this is one of the most underused browser APIs in frontend. You write a worklet — a sandboxed JS module that runs off the main thread — and CSS calls it every time it needs to repaint. Combine that with CSS custom properties as inputs and you've got a system where *CSS drives generative visuals* and JS just defines the algorithm. That inversion is powerful.

What makes it generative? Randomness. But CSS has no Math.random() equivalent — you can't write background: random-color(). So the trick is seeding pseudo-randomness from custom property values, letting you control the output from your stylesheet the same way you'd set any other property. Pass a different --seed: 42 and you get a completely different visual. It's reproducible, composable, and animatable.

Setting Up Your First CSS Paint Worklet

The entry point is CSS.paintWorklet.addModule(). You call this once — typically in your main JS bundle — and hand it a URL to your worklet file. After that, CSS takes over.

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

Inside the worklet file you register a painter class. The browser calls paint(ctx, geom, props) whenever it needs to render. ctx is a limited CanvasRenderingContext2D (no drawImage, no getImageData), geom gives you the element's width and height, and props lets you read your registered custom properties.

// /worklets/noise-painter.js
registerPaint('noise-painter', class {
  static get inputProperties() {
    return ['--seed', '--density', '--color-a', '--color-b'];
  }

  paint(ctx, geom, props) {
    const seed  = parseInt(props.get('--seed').toString()) || 1;
    const density = parseFloat(props.get('--density').toString()) || 0.4;
    const colorA = props.get('--color-a').toString().trim() || '#6366f1';
    const colorB = props.get('--color-b').toString().trim() || '#ec4899';

    // Seeded PRNG — classic mulberry32
    const rand = mulberry32(seed);

    const cellSize = Math.max(4, geom.width * density * 0.08);
    for (let x = 0; x < geom.width; x += cellSize) {
      for (let y = 0; y < geom.height; y += cellSize) {
        const t = rand();
        ctx.fillStyle = lerpColor(colorA, colorB, t);
        ctx.globalAlpha = 0.6 + rand() * 0.4;
        ctx.beginPath();
        ctx.arc(x + rand() * cellSize, y + rand() * cellSize,
                rand() * cellSize * 0.5, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }
});

function mulberry32(a) {
  return function() {
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

function lerpColor(a, b, t) {
  // naive hex lerp — good enough for demo
  const ah = parseInt(a.replace('#',''), 16);
  const bh = parseInt(b.replace('#',''), 16);
  const ar = (ah >> 16) & 0xff, ag = (ah >> 8) & 0xff, ab = ah & 0xff;
  const br = (bh >> 16) & 0xff, bg = (bh >> 8) & 0xff, bb = bh & 0xff;
  const rr = Math.round(ar + (br - ar) * t);
  const rg = Math.round(ag + (bg - ag) * t);
  const rb = Math.round(ab + (bb - ab) * t);
  return `rgb(${rr},${rg},${rb})`;
}

That's the whole worklet. Under 60 lines. Now you wire it up in CSS and suddenly you have a generative background driven entirely by custom properties.

.generative-card {
  --seed: 7;
  --density: 0.5;
  --color-a: #6366f1;
  --color-b: #ec4899;
  background: paint(noise-painter);
  width: 400px;
  height: 240px;
  border-radius: 16px;
}

Making It Actually Random (and Controllable)

Here's the thing people miss: true randomness in a Paint worklet is a bad idea. If paint() is called twice — say during a resize — you want the same visual to come back, not a different one. That's why you seed your PRNG from a custom property. You control *which* random universe you're in. The mulberry32 algorithm above is cheap and fast; it produces a full sequence from a single 32-bit integer, which maps cleanly to an integer CSS custom property.

Want infinite variation without writing JS? You can drive --seed from CSS itself. Generate a value from a class name, from the nth-child index — anything that produces a different integer per element.

/* Different cards, different patterns, zero JS */
.card:nth-child(1)  { --seed: 1; }
.card:nth-child(2)  { --seed: 2; }
.card:nth-child(3)  { --seed: 3; }
/* Or go wild with calc() if you register the property with @property */

Worth noting: if you register --seed with @property { syntax: '<integer>'; inherits: false; initial-value: 1; } you get type-checked custom properties that browsers can animate. Set transition: --seed 0s step-end 0.3s and you can make the pattern *snap* to a new seed after a delay. Not a smooth tween (integers don't tween), but useful for hover effects that feel like randomising.

In practice, I keep --seed on a data attribute and set it in a loop. One attribute, zero repaint unless the seed changes. The worklet only re-runs when its inputs change or the element resizes — that's the Houdini contract.

Going Beyond Circles: Voronoi, Flow Fields, and Tiling

Dots are just the beginning. The Paint API gives you a full 2D canvas API (minus the pixel-reading methods), so you can draw bezier curves, clip paths, or implement any algorithm that fits in CPU time under ~16ms. Three patterns that work especially well at CSS-background scale:

Flow fields. Sample a noise function (you'll need to implement simplex or Perlin inline — no imports in worklets) at each grid point to get an angle, then draw short line segments following that angle. At 6px spacing and 30px length per segment you get those satisfying organic current patterns. The key param is the noise frequency: around 0.008 gives large swirling regions; 0.04 gives chaotic hair-like texture.

Voronoi cells. Drop N seed points pseudo-randomly, then for each pixel find the nearest seed and fill with a color derived from that seed's index. Expensive naively (O(w×h×N)) but workable if you limit N to 20-30 and your element is under 600px wide. You can get surprisingly beautiful stained-glass effects — especially when combined with backdrop-filter: blur(1px) on the element itself. If you're already using glassmorphism components, layering a Voronoi worklet behind the blur creates a completely unique frosted-glass look.

Truchet tiling. Divide the canvas into a grid of, say, 40×40px cells. In each cell, randomly choose one of two tile orientations (quarter-circle arcs going from top-left to bottom-right vs top-right to bottom-left). Done right, the arcs connect across cells and form flowing maze-like paths. This is one of those algorithms that looks impossibly complex but is maybe 25 lines of worklet code.

// Truchet tile — simplified
paint(ctx, geom, props) {
  const rand = mulberry32(parseInt(props.get('--seed').toString()) || 1);
  const size = 40;
  ctx.lineWidth = 3;
  ctx.strokeStyle = '#fff';
  for (let x = 0; x < geom.width; x += size) {
    for (let y = 0; y < geom.height; y += size) {
      ctx.beginPath();
      if (rand() > 0.5) {
        ctx.arc(x, y, size / 2, 0, Math.PI / 2);
        ctx.arc(x + size, y + size, size / 2, Math.PI, 3 * Math.PI / 2);
      } else {
        ctx.arc(x + size, y, size / 2, Math.PI / 2, Math.PI);
        ctx.arc(x, y + size, size / 2, 3 * Math.PI / 2, 2 * Math.PI);
      }
      ctx.stroke();
    }
  }
}

Animating Generative Art With CSS Custom Properties

This is where it gets genuinely fun. Because the worklet reads from custom properties, any CSS animation or transition that changes those properties triggers a repaint. No JS animation loop. No requestAnimationFrame. Just @keyframes.

@property --flow-t {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

.animated-bg {
  --flow-t: 0;
  background: paint(flow-field);
  animation: flow-scroll 8s linear infinite;
}

@keyframes flow-scroll {
  to { --flow-t: 100; }
}

Inside your worklet, read --flow-t and use it as an offset into your noise lookup — effectively scrolling through the noise field over time. The browser handles all the scheduling. You do get compositor-thread animation for transform and opacity, but custom property animations run on the main thread. That's fine for most generative art pieces where you're targeting 30fps anyway. Just keep your paint() fast.

Quick aside: if you want to add user-driven randomness — say, regenerating the pattern on button click — just update --seed via element.style.setProperty('--seed', newValue). The worklet fires immediately on the next paint. One line of JS, no canvas refs, no imperative drawing code.

For inspiration on what CSS animation can look like at its ceiling, look at the tailwind-css-animations and css-scroll-animations approaches — generative worklets slot right into those patterns since both are ultimately just CSS properties changing over time.

Browser Support, Fallbacks, and Performance Caveats

Look, the elephant in the room: Firefox doesn't support CSS Paint API as of mid-2026. It's behind a flag but not shipped. Safari added partial support in Safari 16.4 but has some edge cases around custom property registration. Chromium (Chrome, Edge, Arc, Brave) is solid — has been since Chrome 65.

Your fallback strategy should be layered. First, check for 'paintWorklet' in CSS before calling addModule. In CSS, use @supports (background: paint(anything)) to gate the paint() call and provide a solid gradient or static background for non-supporting browsers. That way users on Firefox still get a nice background; they just don't get the generative version.

.generative-card {
  /* Fallback for Firefox and old Safari */
  background: linear-gradient(135deg, #6366f1, #ec4899);
}

@supports (background: paint(noise-painter)) {
  .generative-card {
    background: paint(noise-painter);
  }
}

On performance: worklets run off the main thread but they *do* block the rasterization pipeline when the output is large. Keep painted areas under ~800×600px or throttle repaints using will-change: background sparingly. If you're painting the entire viewport, consider using the worklet on a fixed-position pseudo-element with pointer-events: none so layout changes on child elements don't trigger unnecessary repaints.

One more thing — the lack of getImageData in Paint worklets is intentional (security sandbox), but it also means you can't read back pixels for physics simulations. If you need that, you still need Canvas 2D. Houdini's sweet spot is *visual decoration at CSS scale*, not full creative-coding environments.

Integrating Generative Backgrounds Into a Component System

Real world usage: you've got a design system — maybe built on top of Empire UI or a similar component library — and you want some cards or hero sections to have generative backgrounds that stay on-brand. The clean approach is a React wrapper that registers the worklet once and exposes seed + palette as props.

import { useEffect } from 'react';

const WORKLET_URL = '/worklets/noise-painter.js';
let registered = false;

export function GenerativeCard({
  seed = 1,
  colorA = '#6366f1',
  colorB = '#ec4899',
  children,
}: {
  seed?: number;
  colorA?: string;
  colorB?: string;
  children: React.ReactNode;
}) {
  useEffect(() => {
    if (!registered && 'paintWorklet' in CSS) {
      CSS.paintWorklet.addModule(WORKLET_URL);
      registered = true;
    }
  }, []);

  return (
    <div
      style={{
        ['--seed' as string]: seed,
        ['--color-a' as string]: colorA,
        ['--color-b' as string]: colorB,
        background: 'paint(noise-painter)',
        borderRadius: '16px',
        padding: '24px',
      }}
    >
      {children}
    </div>
  );
}

The registered flag prevents calling addModule multiple times — it's a no-op after the first call anyway, but it keeps things clean. You could also drive this from a context provider if you're registering multiple worklets.

Generative backgrounds pair especially well with glassmorphism generator output — put a frosted-glass card on top of a generative worklet background and you get depth that no static gradient achieves. The blur picks up the noise pattern and softens it, creating a layered effect that's genuinely hard to replicate with any other approach.

That said, don't overdo it. One or two generative surfaces per page is an accent. Using it on every card turns it from interesting into noise — ironically, the non-generative kind.

FAQ

Does CSS Paint API work in Firefox?

Not shipped as of mid-2026 — it's behind a flag. Always provide a CSS gradient fallback using @supports (background: paint(anything)) so Firefox users still get a usable background.

Can I animate a CSS Paint worklet?

Yes. Animate any registered custom property with @keyframes and the worklet repaints automatically. Register properties with @property first so the browser knows the type and can interpolate.

Why can't I use Math.random() directly in the worklet?

You can, but the result won't be deterministic across repaints — the pattern changes every time the browser redraws. Seed a PRNG from a custom property instead so the visual is stable and reproducible.

Is CSS Houdini the same as the CSS Paint API?

Houdini is the umbrella spec covering Paint, Layout, Animation, and Properties APIs. CSS Paint API is one piece of it — the part that lets you draw custom backgrounds and borders.

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

Read next

CSS Houdini Paint Worklet: Custom CSS Properties With GPU PowerCSS Houdini Paint Worklet: Custom Backgrounds No One Else HasWhat Is Neumorphism? Soft UI Explained with Free React CodeNeobrutalism with Tailwind: offset-y Shadows, Bold Borders, Raw Typography