paint()

Image
Gabriel Shoyombo on

Get affordable and hassle-free WordPress hosting plans with Cloudways — start your free trial today.

Experimental: Check browser support before using this in production.

The paint() function allows us to use PaintWorklets as background images in CSS. PaintWorklets are images created in JavaScript that are applied to an element in CSS with the paint() function.

CSS.paintWorklet.addModule("polka-worklet.js");
.element {
  width: 100vw;
  height: 100vh;
  background-image: paint(polka-dots);
}

The paint() function is defined in the CSS Painting API Level 1 specification. It is part of a larger set of features related to CSS Houdini.

Syntax

The paint() function syntax is straightforward: it accepts a single required value, which is the name of the PaintWorklet that is defined in JavaScript:

paint() = paint( <ident>, <declaration-value>? )

And it can be used wherever an <image> value is expected:

.element {
  background-image: paint(cool-bg); /*  cool-bg is the worklet's name */
}

Arguments

The paint() function actually takes up to two arguments, the other of which is optional:

  • <ident>: The name of the registered worklet defined in the JavaScript file. Similar to the name of an image file when using any <image>-valued function.
  • <declaration-value> (optional): Can be used to set additional parameters within the PaintWorklet class. However, it is not currently supported by any browser at the time of writing.

What do we mean by “parameters” for the second optional value? We don’t know for sure until the specification becomes a formal candidate recommendation, but the idea is that we can, apply additional styling to the image at the same time we call it, for example, adding a stroke to the background image:

.element {
  background-image: paint(cool-bg, stroke, 5px);
}

What we do know is that parameters will support CSS Custom Properties, which we’ll cover a bit later.

Painting API?

The paint() function allows us to import an <image> created with the CSS Painting API. The API is one of the several low-level APIs that allow developers to interact with the CSS Object Model. Together, they are classified as CSS Houdini APIs that allow us to write JavaScript code that the browser parses as CSS.

What is Houdini? It gives us the chance to create custom styles that are not natively available in CSS. The Paint API works by registering a PaintWorklet, which is a script that handles painting and runs on a separate thread. This separation prevents the painting logic from blocking the main thread, thus improving performance.

Basic usage

To use the paint() function we first…

  1. Define the PaintWorklet as a class that handles the painting. We usually define it in a separate file, which in the case of the beginning example was polka-worklet.js. Inside, we define the class that draws the pattern. The paint() method gives you access to a 2D context like in an HTML <canvas> element, allowing us to draw any 2D design as we please.
// polka-worklet.js
class PolkaDots {
  paint(ctx, geom, props) {
    const {width: w, height: h} = geom;
    const size = 8;
    const color = "#6b7280";
    const scale = 1;

    ctx.fillStyle = color;
    const step = size * 4 * scale;
    for (let y = 0; y < h + step; y += step) {
      for (let x = 0; x < w + step; x += step) {
        ctx.beginPath();
        ctx.arc(x + size, y + size, size * scale, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }
}

We have to write the class at the start of the file because, unlike JavaScript functions, classes cannot be hoisted and need to be defined before they are used.

  1. Once the PaintWorklet is defined, we have to register it within the same file:
// polka-worklet.js
if (typeof registerPaint !== "undefined") {
  registerPaint("polka-dots", PolkaDots);
}
  1. Next, we load the PaintWorklet file into the main JavaScript file using the CSS.paintWorklet.addModule() method:
// index.js, main.js, or your-main-js-file.js
if ("paintWorklet" in CSS) {
  CSS.paintWorklet.addModule("/polka-dots.js");
}
  1. Finally, we can use it in our CSS file using any CSS property that expects an <image> value, such as background-image:
.canvas {
  width: 100vw;
  height: 100vh;
  background-image: paint(polka-dots);
}

It supports CSS Custom Properties

Imagine we have some CSS Custom Properties that we want to use in our PaintWorklet:

.canvas {
  --dot-count: 180;
  --dot-min: 3;
  --dot-max: 50;
  --dot-opacity: 0.9;
}

These properties are defined once within the class’s static getter method, inputProperties. The method informs the browser that the custom properties are accessible with the paint() method — and we can declare those properties as the second optional paint() parameter.

Let’s build off of the first demo we looked at together and make a multi-colored polka dot background pattern made out of varying dot sizes. This paint uses the Math.random() method to generate different hex codes on refresh, giving the website a new look every time.

The flow is the same four-step process as before!

  1. First, create the helper functions and export the file.
// Gets the property from the prop, returns the value if it's a valid number, or else, it returns the fallback
export function readNum(p, name, fallback) {
  const v = p.get(name);
  if (v && typeof v.value === "number") return v.value;
  const n = parseFloat(v?.toString?.() ?? "");
  return Number.isFinite(n) ? n : fallback;
}

// Returns a random hex code
export function randHex() {
  return (
    "#" +
    Math.floor(Math.random() * 0xffffff)
      .toString(16)
      .padStart(6, "0")
  );
}

// Converts hex code to rgba
export function hexToRgba(hex, a) {
  let h = hex.replace("#", "");
  if (h.length === 3)
    h = h
      .split("")
      .map((c) => c + c)
      .join("");
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return `rgba(${r}, ${g}, ${b}, ${a})`;
}

// Returns a number within a given low and high range
export function clamp(n, lo, hi) {
  return Math.max(lo, Math.min(hi, n));
}
  1. Then, we can create a custom class for the PaintWorklet and register it.
// circular-worklet.js

import { readNum, randHex, hexToRgba, clamp} from "./helpers.js"; }

class CircularPatterns {
  static get inputProperties() {
    return ["--dot-count", "--dot-min", "--dot-max", "--dot-opacity"];
  }

  paint(ctx, geom, props) {
    const { width: w, height: h } = geom;

    const count = readNum(props, "--dot-count", 120);
    const minR = readNum(props, "--dot-min", 4);
    const maxR = Math.max(minR + 1, readNum(props, "--dot-max", Math.min(w, h) / 8));
    const alpha = clamp(readNum(props, "--dot-opacity", 1), 0, 1);

    // Build a small random palette of hex colors
    const paletteSize = 6;
    const colors = Array.from({length: paletteSize}, randHex);

    for (let i = 0; i < count; i++) {
      const r = minR + Math.random() * (maxR - minR);
      const x = Math.random() * w;
      const y = Math.random() * h;
      const hex = colors[(Math.random() * colors.length) | 0];

      ctx.fillStyle = alpha === 1 ? hex : hexToRgba(hex, alpha);
      ctx.beginPath();
      ctx.arc(x, y, r, 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

if (typeof registerPaint !== "undefined") {
  registerPaint("circular-patterns", CircularPatterns);
}
  1. Next, let’s add the PaintWorklet file to our main.js file, or in a <script> in the HTML file where it’s being used.
// main.js
CSS.paintWorklet.addModule("circular-worklet.js");
  1. Finally, we can use the paint in your CSS file as an <image> value.
.canvas {
  width: 100vw;
  height: 100vh;
  background-image: paint(circular-patterns);
}

On every rerun, you get a different background pattern!

Creating generative backgrounds with paint()

The CSS Paint API isn’t new by any means — in fact, CSS-Tricks has covered it a lot over the years — and developers have made amazing background images with it. For example, I came across George Francis’s article, “Creating Generative Patterns with the CSS Paint API”, from 2021 where he shares three awesome patterns.

I love George’s “Voronoi Arcs” background example:

There are several cool things going around here:

  1. First, he creates a PaintWorklet class and declares the custom properties he wants to use, gets them within the paint() method using the props parameter, and stores them in respective variables.
  2. Then, he uses a pseudo-random number generator. Unlike Math.random(), this approach ensures the same stream of random numbers is returned during every render.
  3. Finally, he uses ctx to draw a background color upon which the patterns will be, sets the dimensions of the container, then uses the createVoronoiTessellation() function to pass in a number of required arguments to create the pattern.

CSS paint() PaintWorklet vs HTML <canvas>

Can’t we use HTML <canvas> for drawing images? Good question! Both paint() and <canvas> have access to the 2D context, but they differ in several key ways, as illustrated in the table below:

CSS paint()HTML <canvas>
What it isA CSS image function whose pixels are produced by a Paint WorkletAn imperative drawing surface in the DOM controlled via JavaScript (getContext('2d'))
How you use itDeclare a worklet in a separate JavaScript module and reference it in CSS as you would an any other image file.Create a <canvas> element, size it, then draw to its context in JavaScript.
InputsCSS custom properties (--vars) and additional parameters (where supported)Any JavaScript data, including arrays, images, fetched resources, and user input
Drawing APISubset of Canvas 2D only (no images/fonts can be loaded inside of the worklet)Canvas 2D, WebGL, WebGL2, WebGPU, images, and text metrics
Best forBackgrounds, borders, masks, underlines that are applied to style elements in CSSInteractive graphics, games, data visualizations, real-time video, and image processing

Specification

The paint() function is defined in the CSS Painting API Level 1 specification, which is currently in Editor’s Draft. That means the information can change between now and when it formally becomes a Candidate Recommendation for browsers to implement.

Browser support

The <ident> argument works in the listed browsers, but the <declaration-value>, which indicates additional parameters, is not supported in any browser at all.

To be on the safe side, add a fallback image just in case your user uses an unsupported browser.

.canvas {
  /* Fallback image everyone can render */
  background: #f9fafb url("/images/dots-fallback.png") center/cover no-repeat;
}

/* Only browsers that understand paint() will apply this */
@supports (background-image: paint(polka-dots)) {
  .canvas {
    background: #f9fafb;
    background-image: paint(polka-dots);
    background-size: cover;
    background-repeat: no-repeat;
  }
}

References