Drawing Graphics with the CSS Paint API

A practical introduction to the CSS Paint API with hands-on examples.

CSS Paint is an API that allows developers to programatically generate and draw graphics where CSS expects an image.

It is part of CSS Houdini, an umbrella term for seven new low-level APIs that expose different parts of the CSS engine and allows developers to extend CSS by hooking into the styling and layout process of a browser’s rendering engine.

It enables developers to write code the browser can parse as CSS, thereby creating new CSS features without waiting for them to be implemented natively in browsers.

Today we will explore two particular APIs, that are part of the CSS Houdini umbrella:

  1. CSS Paint, which at the time of writing this article, has been fully implemented in Chrome, Opera and Edge and is available in Firefox and Safari via a polyfill.
  2. CSS Properties and Values API, that will allow us to explicitly define our CSS variables, their initial values, what type of values they support and whether these variables can be inherited.

CSS Paint provides us with ability to render graphics using a PaintWorklet, a stripped down version of the CanvasRenderingContext2D. The major differences are:

  • No support for text rendering
  • No direct pixel access / manipulation

With these two omissions in mind, anything you can draw using canvas2d, you can draw on a regular DOM element using the CSS Paint API. For those of you who have done any graphics using canvas2d, you should be right at home.

Furthermore, we as developers have the ability to pass CSS variables as inputs to our PaintWorklet and control its presentation using custom predefined attributes.

This allows for a high degree of customisation, even by design people who may not be necessarily familiar with Javascript.

You can see more examples here and here. And with that out of the way, let’s get to coding!

Simplest example: two diagonal lines

Let’s create a CSS paintlet, that once loaded, will draw two diagonal lines across the surface of the DOM element we apply it to. The paintlet drawing surface size will adapt to the width and height of the DOM element and we will be able to control the diagonal line thickness by passing in a CSS variable.

Creating our PaintWorklet

In order to load a PaintWorklet, we will need to create it as a separate Javascript file (diagonal-lines.js).

const PAINTLET_NAME = 'diagonal-lines'

class CSSPaintlet {

  // πŸ‘‰ Define the names of the input CSS variables we will support
  static get inputProperties() {
    return [
      `--${PAINTLET_NAME}-line-width`,
    ]
  }

  // πŸ‘‰ Define names for input CSS arguments supported in paint()
  // ⚠️ This part of the API is still experimental and hidden
  //    behind a flag.
  static get inputArguments () {
    return []
  }

  // πŸ‘‰ paint() will be executed every time:
  //  - any input property changes
  //  - the DOM element we apply our paintlet to changes its dimensions
  paint(ctx, paintSize, props) {
    // πŸ‘‰ Obtain the numeric value of our line width that is passed
    //    as a CSS variable
    const lineWidth = Number(props.get(`--${PAINTLET_NAME}-line-width`))

    ctx.lineWidth = lineWidth

    // 🎨 Draw diagonal line #1
    ctx.beginPath()
    ctx.moveTo(0, 0)
    ctx.lineTo(paintSize.width, paintSize.height)
    ctx.stroke()

    // 🎨 Draw diagonal line #2
    ctx.beginPath()
    ctx.moveTo(0, paintSize.height)
    ctx.lineTo(paintSize.width, 0)
    ctx.stroke()
  }
}

// πŸ‘‰ Register our CSS Paintlet with the correct name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

We define our CSS paintlet as a standalone class. This class needs only one method to work – paint(), which will draw the graphics on top of the surface we assign our CSS paintlet to. It will be executed upon changing any of the CSS variables our paintlet relies on or when our DOM element changes it’s dimensions.

The other static method inputProperties() is optional. It tells the CSS paintlet which input CSS variables exactly does it support. In our case, that would be --diagonal-lines-line-width. We declare it as an input property and consume it for use in our paint() method. It is important we cast it to a number by putting it in a Number to ensure cross-browser support.

There is yet another optional static method supported: inputArguments. It exposes arguments to our paint() method like so:

#myImage {
  background-image: paint(myWorklet, 30px, red, 10deg);
}

However, this part of the CSS paintlet API is still hidden behind a flag and considered experimental. For ease of use and compatability, we will not be covering it in this article, but I encourage you to read up on it on your own. Instead, we will use CSS variables using the inputProperties() method to control all of the inputs to our paintlet.

Tiny break: 📬 Want to stay up to date with frontend and trends in web design? Subscribe and get our Collective newsletter twice a tweek.

Registering our CSS PaintWorklet

Afterwards we must reference our CSS paintlet and register it to our main page. It is important we conditionally load the awesome css-paint-polyfill package, which will ensure our paintlets will work in Firefox and Safari.

It should be noted that along our CSS paintlet, we can use the new CSS Properties and Values API, also part of the Houdini umbrella, to explicitly define our CSS variables inputs via CSS.registerProperty(). We can control our CSS variables like so:

  • Their types & syntax
  • Whether this CSS variable inherits from any parent elements
  • What is it’s initial value if the user does not specify one

This API is also not supported in Firefox and Safari, but we can still use it in Chromium browsers. This way we will future-proof our demos and browsers that don’t support it will simply ignore it.

;(async function() {
  // ⚠️ Handle Firefox and Safari by importing a polyfill for CSS Pain    
  if (CSS['paintWorklet'] === undefined) {
    await import('https://unpkg.com/css-paint-polyfill')
  }

  // πŸ‘‰ Explicitly define our custom CSS variable
  //    This is not supported in Safari and Firefox, so they will
  //    ignore it, but we can optionally use it in browsers that 
  //    support it. 
  //    This way we will future-proof our applications so once Safari
  //    and Firefox support it, they will benefit from these
  //    definitions too.
  //
  //    Make sure that the browser treats it as a number
  //    It does not inherit it's value
  //    It's initial value defaults to 1
  if ('registerProperty' in CSS) {
    CSS.registerProperty({
      name: '--diagonal-lines-line-width',
      syntax: '<number>',
      inherits: false,
      initialValue: 1
    })
  }

  // πŸ‘‰ Include our separate paintlet file
  CSS.paintWorklet.addModule('path/to/our/external/worklet/diagonal-files.js')
})()

Referencing our paintlet as a CSS background

Once we have included our paintlet as a JS file, using it is dead simple. We select our target DOM element we want to style in CSS and apply our paintlet via the paint() CSS command:

#myElement {
   // πŸ‘‰ Reference our CSS paintlet
   background-image: paint('--diagonal-lines');

   // πŸ‘‰ Pass in custom CSS variable to be used in our CSS paintlet
   --diagonal-lines-line-width: 10;

   // πŸ‘‰ Remember - the browser treats this as a regular image
   // referenced in CSS. We can control it's repeat, size, position
   // and any other background related property available
   background-repeat: no-repeat;
   background-size: cover;
   background-position: 50% 50%;

   // Some more styles to make sure we can see our element on the page
   border: 1px solid red;
   width: 200px;
   height: 200px;
   margin: 0 auto;
}

And with this code out of the way, here is what we will get:

Remember, we can apply this CSS paintlet as a background to any DOM element with any dimensions. Let’s blow up our DOM element to fullscreen, lower it’s background-size x and y values and set it’s background-repeat to repeat. Here is our updated example:

We are using the same CSS paintlet from our previous example, but now we have expanded it to cover the whole demo page.

So now that we covered our base example and saw how to organise our code, let’s write some nicer looking demos!

Particle Connections

This paintlet was inspired by the awesome demo by @nucliweb.

Again, for those of you who have used the canvas2d API to draw graphics in the past, this will be pretty straightforward.

We control how many points we are going to render via the `–dots-connections-count` CSS variable. Once we obtain its numeric value in our paintlet, we create an array with the appropriate size and fill it with objects with random x, y and radius properties.

Then we loop each of our items in the array, draw a sphere at its coordinates, find the nearest neighbour to it (the minimum distance is controlled via the `–dots-connections-connection-min-dist` CSS variable) and connect them with a line.

We will also control the spheres fill color and the lines stroke color via the `–dots-connections-fill-color` and --dots-connections-stroke-color CSS variables respectively.

Here is the complete workled code:

const PAINTLET_NAME = 'dots-connections'

class CSSPaintlet {
  // πŸ‘‰ Define names for input CSS variables we will support
  static get inputProperties() {
    return [
      `--${PAINTLET_NAME}-line-width`,
      `--${PAINTLET_NAME}-stroke-color`,
      `--${PAINTLET_NAME}-fill-color`,
      `--${PAINTLET_NAME}-connection-min-dist`,
      `--${PAINTLET_NAME}-count`,
    ]
  }

  // πŸ‘‰ Our paint method to be executed when CSS vars change
  paint(ctx, paintSize, props, args) {
    const lineWidth = Number(props.get(`--${PAINTLET_NAME}-line-width`))
    const minDist = Number(props.get(`--${PAINTLET_NAME}-connection-min-dist`))
    const strokeColor = props.get(`--${PAINTLET_NAME}-stroke-color`)
    const fillColor = props.get(`--${PAINTLET_NAME}-fill-color`)
    const numParticles = Number(props.get(`--${PAINTLET_NAME}-count`))
    
    // πŸ‘‰ Generate particles at random positions
    //    across our DOM element surface
    const particles = new Array(numParticles).fill(null).map(_ => ({
      x: Math.random() * paintSize.width,
      y: Math.random() * paintSize.height,
      radius: 2 + Math.random() * 2,
    }))
    
    // πŸ‘‰ Assign lineWidth coming from CSS variables and make sure
    //    lineCap and lineWidth are round
    ctx.lineWidth = lineWidth
    ctx.lineJoin = 'round'
    ctx.lineCap = 'round'
    
    // πŸ‘‰ Loop over the particles with nested loops - O(n^2)
    for (let i = 0; i < numParticles; i++) {
      const particle = particles[i]
      // πŸ‘‰ Loop second time 
      for (let n = 0; n < numParticles; n++) {
        if (i === n) {
          continue
        }
        const nextParticle = particles[n]
        // πŸ‘‰ Calculate distance between the current particle
        //    and the particle from the previous loop iteration
        const dx = nextParticle.x - particle.x
        const dy = nextParticle.y - particle.y
        const dist = Math.sqrt(dx * dx + dy * dy)
        // πŸ‘‰ If the dist is smaller then the minDist specified via
        //    CSS variable, then we will connect them with a line
        if (dist < minDist) {
          ctx.strokeStyle = strokeColor
          ctx.beginPath()
          ctx.moveTo(nextParticle.x, nextParticle.y)
          ctx.lineTo(particle.x, particle.y)
          // πŸ‘‰ Draw the connecting line
          ctx.stroke()
        }
      }
      // Finally draw the particle at the right position
      ctx.fillStyle = fillColor
      ctx.beginPath()
      ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2)
      ctx.closePath()
      ctx.fill()
    }
    
  }
}

// πŸ‘‰ Register our CSS paintlet with a unique name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

Line Loop

Here is our next example. It expects the following CSS variables as inputs to our paintlet:

--loop-line-width
--loop-stroke-color
--loop-sides
--loop-scale
--loop-rotation

We loop around a full circle (PI * 2) and position them along it’s perimeters based on the --loop-sides count CSS variables. For each position, we loop again around our full circle, and connect it to all other positions via a ctx.lineTo() command:

const PAINTLET_NAME = 'loop'

class CSSPaintlet {
  // πŸ‘‰ Define names for input CSS variables we will support
  static get inputProperties() {
    return [
      `--${PAINTLET_NAME}-line-width`,
      `--${PAINTLET_NAME}-stroke-color`,
      `--${PAINTLET_NAME}-sides`,
      `--${PAINTLET_NAME}-scale`,
      `--${PAINTLET_NAME}-rotation`,
    ]
  }
  // πŸ‘‰ Our paint method to be executed when CSS vars change
  paint(ctx, paintSize, props, args) {
    const lineWidth = Number(props.get(`--${PAINTLET_NAME}-line-width`))
    const strokeColor = props.get(`--${PAINTLET_NAME}-stroke-color`)
    const numSides = Number(props.get(`--${PAINTLET_NAME}-sides`))
    const scale = Number(props.get(`--${PAINTLET_NAME}-scale`))
    const rotation = Number(props.get(`--${PAINTLET_NAME}-rotation`))
    
    const angle = Math.PI * 2 / numSides
    const radius = paintSize.width / 2
    ctx.save()
    ctx.lineWidth = lineWidth
    ctx.lineJoin = 'round'
    ctx.lineCap = 'round'
    ctx.strokeStyle = strokeColor
    ctx.translate(paintSize.width / 2, paintSize.height / 2)
    ctx.rotate(rotation * (Math.PI / 180))
    ctx.scale(scale / 100, scale / 100)
    ctx.moveTo(0, radius)

    // πŸ‘‰ Loop over the numsides twice in nested loop - O(n^2)
    //    Connect each corner with all other corners
    for (let i = 0; i < numSides; i++) {
      const x = Math.sin(i * angle) * radius
      const y = Math.cos(i * angle) * radius
      for (let n = i; n < numSides; n++) {
        const x2 = Math.sin(n * angle) * radius
        const y2 = Math.cos(n * angle) * radius
        ctx.lineTo(x, y)
        ctx.lineTo(x2, y2);
      }
    }
    ctx.closePath()
    ctx.stroke()
    ctx.restore()
  }   
}

// πŸ‘‰ Register our CSS paintlet with a unique name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

Noise Button

Here is our next example. It is inspired by this other awesome CSS Paintlet by Jhey Tompkins. It expects the following CSS variables as inputs to our paintlet:

--grid-size
--grid-color
--grid-noise-scale

The paintlet itself uses perlin noise (code courtesy of joeiddon) to control the opacity of each individual cell.

const PAINTLET_NAME = 'grid'

class CSSPaintlet {
  // πŸ‘‰ Define names for input CSS variables we will support
  static get inputProperties() {
    return [
      `--${PAINTLET_NAME}-size`,
      `--${PAINTLET_NAME}-color`,
      `--${PAINTLET_NAME}-noise-scale`
    ]
  }

  // πŸ‘‰ Our paint method to be executed when CSS vars change
  paint(ctx, paintSize, props, args) {
    const gridSize = Number(props.get(`--${PAINTLET_NAME}-size`))
    const color = props.get(`--${PAINTLET_NAME}-color`)
    const noiseScale = Number(props.get(`--${PAINTLET_NAME}-noise-scale`))

    ctx.fillStyle = color
    for (let x = 0; x < paintSize.width; x += gridSize) {
      for (let y = 0; y < paintSize.height; y += gridSize) {
        // πŸ‘‰ Use perlin noise to determine the cell opacity
        ctx.globalAlpha = mapRange(perlin.get(x * noiseScale, y * noiseScale), -1, 1, 0.5, 1)
        ctx.fillRect(x, y, gridSize, gridSize)
      }
    }
  }
}

// πŸ‘‰ Register our CSS paintlet with a unique name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

Curvy dividers

As a last example, let’s do something perhaps a bit more useful. We will programatically draw dividers to separate the text content of our page:

And as usual, here is the CSS paintlet code:

const PAINTLET_NAME = 'curvy-dividor'

class CSSPaintlet {
  // πŸ‘‰ Define names for input CSS variables we will support
  static get inputProperties() {
    return [
      `--${PAINTLET_NAME}-points-count`,
      `--${PAINTLET_NAME}-line-width`,
      `--${PAINTLET_NAME}-stroke-color`
    ]
  }
  // πŸ‘‰ Our paint method to be executed when CSS vars change
  paint(ctx, paintSize, props, args) {
    const pointsCount = Number(props.get(`--${PAINTLET_NAME}-points-count`))
    const lineWidth = Number(props.get(`--${PAINTLET_NAME}-line-width`))
    const strokeColor = props.get(`--${PAINTLET_NAME}-stroke-color`)
    
    const stepX = paintSize.width / pointsCount
    
    ctx.lineWidth = lineWidth
    ctx.lineJoin = 'round'
    ctx.lineCap = 'round'
    
    ctx.strokeStyle = strokeColor
    
    const offsetUpBound = -paintSize.height / 2
    const offsetDownBound = paintSize.height / 2
    
    // πŸ‘‰ Draw quadratic bezier curves across the horizontal axies
    //    of our dividers:
    ctx.moveTo(-stepX / 2, paintSize.height / 2)
    for (let i = 0; i < pointsCount; i++) {
      const x = (i + 1) * stepX - stepX / 2
      const y = paintSize.height / 2 + (i % 2 === 0 ? offsetDownBound : offsetUpBound)
      const nextx = (i + 2) * stepX - stepX / 2
      const nexty = paintSize.height / 2 + (i % 2 === 0 ? offsetUpBound : offsetDownBound)
      const ctrlx = (x + nextx) / 2
      const ctrly = (y + nexty) / 2
      ctx.quadraticCurveTo(x, y, ctrlx, ctrly)
    }
    ctx.stroke()
  }
}

// πŸ‘‰ Register our CSS paintlet with a unique name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

Conclusion

In this article we went through all the key components and methods of the CSS Paint API. It is pretty easy to setup and very useful if we want to draw more advanced graphics that CSS does not support out-of-the box.

We can easily create a library out of these CSS paintlets and keep reusing them across our projects with minimum setup required.

As a good practice, I encourage you to find cool canvas2d demos and port them to the new CSS Paint API.

Georgi Nikolov

I am a frontend developer living and working in Berlin. I specialise in developing rich user interfaces and graphics, such as websites, web apps, animations and visualisations. During my spare time, I learn and strive to improve my programming skills and math for computer graphics / animation.

Stay in the loop: Get your dose of frontend twice a week

πŸ‘Ύ Hey! Looking for the latest in frontend? Twice a week, we'll deliver the freshest frontend news, website inspo, cool code demos, videos and UI animations right to your inbox.

Zero fluff, all quality, to make your Mondays and Thursdays more creative!