From our sponsor: Chromatic - Visual testing for Storybook, Playwright & Cypress. Catch UI bugs before your users do.
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:
- 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.
- 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.