Simulating Drop Shadows with the CSS Paint API
Get affordable and hassle-free WordPress hosting plans with Cloudways —start your free trial today.
Ask a hundred front-end developers, and most, if not all, of them will have used thebox-shadow property in their careers. Shadows are enduringly popular, and can add an elegant, subtle effect if used properly. But shadows occupy a strange place in the CSS box model. They have no effect on an element’s width and height, and are readily clipped if overflow on a parent (or grandparent) element is hidden.
We can work around this with standard CSS in a few different ways. But, now that some of theCSS Houdini specifications are being implemented in browsers, there are tantalizing new options. TheCSS Paint API, for example, allows developers to generate images programmatically at run time. Let’s look at how we can use this topaint a complex shadow within a border image.
A quick primer on Houdini
You may have heard of some newfangled CSS tech hitting the platform with the catchy name ofHoudini. Houdini promises to deliver greater access to how the browser paints the page. As MDN states, it is “a set of low-level APIs that exposes parts of the CSS engine, giving developers the power to extend CSS by hooking into the styling and layout process of a browser’s rendering engine.”
The CSS Paint API
The CSS Paint API is one of the first of these APIs to hit browsers. It is aW3C candidate recommendation. This is the stage when specifications start to see implementation. It is currently available for general use in Chrome and Edge, while Safari has it behind a flag and Firefox lists it as“worth prototyping”. There is apolyfill available for unsupported browsers, though it will not run in IE11.
While the CSS Paint API is enabled in Chromium, passing arguments to thepaint() function is still behind a flag. You’ll need toenable experimental web platform features for the time being. These examples may not, unfortunately, work in your browser of choice at the moment. Consider them an example of things to come, and not yet ready for production.
The approach
We’re going to generate an image with a shadow, and then use it for aborder-image… huh? Well, let’s take a deeper look.
As mentioned above, shadows don’t add any width or height to an element, but spread out from its bounding box. In most cases, this isn’t a problem, but those shadows are vulnerable to clipping. A common workaround is tocreate some sort of offset with either padding or margin.
What we’re going to do is build the shadowright into the element by painting it in to theborder-image area. This has a few key advantages:
border-widthadds to the overall element width- Content won’t spill into the border area and overlap the shadow
- Padding won’t need any extra width to accommodate the shadowand content
- Margins around the element won’t interfere with that element’s siblings
For that aforementioned group of one hundred developers who’ve usedbox-shadow, it’s likely only a few of them have usedborder-image. It’s a funky property. Essentially, it takes an image and slices it into nine pieces, then places them in the four corners, sides and (optionally) the center. You can read more about how all this works inNora Brown’s article.
The CSS Paint API will handle the heavy lifting of generating the image. We’re going to create a module for it that tells it how to layer a series of shadows on top of each other. That image will then get used byborder-image.
These are the steps we’ll take:
- Set up the HTML and CSS for the element we want to paint in
- Create amodule that draws the image
- Load the module into apaint worklet
- Call the worklet in CSS with the new
paint()function
Setting up the canvas
You’re going to hear the termcanvas a few times here, and in other CSS Paint API resources. If that term sounds familiar, you’re right. The API works in a similar way to the HTML<canvas> element.
First, we have to set up the canvas on which the API will paint. This area will have the same dimensions as the element that calls the paint function. Let’s make a 300×300 div.
<section> <div></div></section>And the styles:
.foo { border: 15px solid #efefef; box-sizing: border-box; height: 300px; width: 300px;}Creating the paint class
HTTPS is required forany JavaScript worklet, including paint worklets. You won’t be able to use it at all if you’re serving your content over HTTP.
The second step is to create themodule that is loaded into the worklet — a simple file with theregisterPaint() function. This function takes two arguments: the name of the worklet and a class that has the painting logic. To stay tidy, we’ll use an anonymous class.
registerPaint( "shadow", class {});Inour case, the class needs two attributes,inputProperties andinputArguments, and a method,paint().
registerPaint( "shadow", class { static get inputProperties() { return []; } static get inputArguments() { return []; } paint(context, size, props, args) {} });inputProperties andinputArguments are optional, but necessary to pass data into the class.
Adding input properties
We need to tell the worklet which CSS properties to pull from the target element withinputProperties. It’s a getter that returns an array of strings.
In this array, we list both the customand standard properties the class needs:--shadow-colors,background-color, andborder-top-width. Pay particular attention to how we use non-shorthand properties.
static get inputProperties() { return ["--shadow-colors", "background-color", "border-top-width"];}For simplicity, we’re assuming here that the border is even on all sides.
Adding arguments
Currently,inputArguments are still behind a flag, hence enabling experimental features. Without them, useinputProperties and custom properties instead.
We also pass arguments to the paint module withinputArguments. At first glance, they may seem superfluous toinputProperties, but there are subtle differences in how the two are used.
When the paint function is called in the stylesheet,inputArguments are explicitly passed in thepaint() call. This gives them an advantage overinputProperties, which might be listening for properties that could be modified by other scripts or styles. For example, if you’re using a custom property set on:root that changes, it may filter down and affect the output.
The second important difference forinputArguments, which is not intuitive, is that they arenot named. Instead, they are referenced as items in an array within the paint method. When we tellinputArguments what it’s receiving, we are actually giving it thetype of the argument.
Theshadow class is going to need three arguments: one for X positions, one for Y positions, and one for blurs. We’ll set that up as three space-separated lists of integers.
Anyone who has registered a custom property may recognize the syntax. In our case, the<integer> keyword means anywhole number, while+ denotes a space-separated list.
static get inputArguments() { return ["<integer>+", "<integer>+", "<integer>+"];}To useinputProperties in place ofinputArguments, you could set custom properties directly on the element and listen for them. Namespacing would be critical to ensure inherited custom properties from elsewhere don’t leak in.
Adding the paint method
Now that we have the inputs, it’s time to set up the paint method.
A key concept forpaint() is thecontext object. It is similar to, and works much like, the HTML<canvas> element context, albeit with a few small differences. Currently, you cannot read pixels back from the canvas (for security reasons), or render text (there’s a brief explanation why inthis GitHub thread).
Thepaint() method has four implicit parameters:
- The context object
- Geometry (an object with width and height)
- Properties (a map from
inputProperties) - Arguments (the arguments passed from the stylesheet)
paint(ctx, geom, props, args) {}Getting the dimensions
Thegeometry object knows how big the element is, but we need to adjust for the 30 pixels of total border on the X and Y axis:
const width = (geom.width - borderWidth * 2);const height = (geom.height - borderWidth * 2);Using properties and arguments
Properties andarguments hold the resolved data frominputProperties andinputArguments. Properties come in as a map-like object, and we can pull values out withget() andgetAll():
const borderWidth = props.get("border-top-width").value;const shadowColors = props.getAll("--shadow-colors");get() returns a single value, whilegetAll() returns an array.
--shadow-colors will be a space-separated list of colors which can be pulled into an array. We’ll register this with the browser later so it knows what to expect.
We also need to specify what color to fill the rectangle with. It will use the same background color as the element:
ctx.fillStyle = props.get("background-color").toString();As mentioned earlier, arguments come into the module as an array, and we reference them by index. They’re of the typeCSSStyleValue right now — let’s make it easier to iterate through them:
- Convert the
CSSStyleValueinto a string with itstoString()method - Split the result on spaces with a regex
const blurArray = args[2].toString().split(/\s+/);const xArray = args[0].toString().split(/\s+/);const yArray = args[1].toString().split(/\s+/);// e.g. ‘1 2 3’ -> [‘1’, ‘2’, ‘3’]Drawing the shadows
Now that we have the dimensions and properties, it’s time to draw something! Since we need a shadow for each item inshadowColors, we’ll loop through them. Start with aforEach() loop:
shadowColors.forEach((shadowColor, index) => { });With the index of the array, we’ll grab the matching values from the X, Y, and blur arguments:
shadowColors.forEach((shadowColor, index) => { ctx.shadowOffsetX = xArray[index]; ctx.shadowOffsetY = yArray[index]; ctx.shadowBlur = blurArray[index]; ctx.shadowColor = shadowColor.toString();});Finally, we’ll use thefillRect() method to draw in the canvas. It takes four arguments: X position, Y position, width, and height. For the position values, we’ll useborder-width frominputProperties; this way, theborder-image is clipped to contain just the shadowaroundthe rectangle.
shadowColors.forEach((shadowColor, index) => { ctx.shadowOffsetX = xArray[index]; ctx.shadowOffsetY = yArray[index]; ctx.shadowBlur = blurArray[index]; ctx.shadowColor = shadowColor.toString(); ctx.fillRect(borderWidth, borderWidth, width, height);});This technique canalso be done using a canvas drop-shadow filter and asingle rectangle. It’ssupported in Chrome, Edge, and Firefox, but not Safari.See a finished example on CodePen.
Almost there! There are just a few more steps to wire things up.
Registering the paint module
We first need to register our module as apaint worklet with the browser. This is done back in our main JavaScript file:
CSS.paintWorklet.addModule("https://codepen.io/steve_fulghum/pen/bGevbzm.js");https://codepen.io/steve_fulghum/pen/BazexJXRegistering custom properties
Something else we should do, but isn’tstrictly necessary, is to tell the browser a little more about our custom properties byregistering them.
Registering properties gives them atype. We want the browser to know that--shadow-colors is alist of actual colors, not just a string.
If you need to target browsers that don’t support the Properties and Values API, don’t despair! Custom properties can still be read by the paint module, even if not registered. However, they will be treated as unparsed values, which are effectively strings. You’ll need to add your own parsing logic.
LikeaddModule(), this is added to the main JavaScript file:
CSS.registerProperty({ name: "--shadow-colors", syntax: "<color>+", initialValue: "black", inherits: false});You can also use@property in your stylesheet to register properties. You can read abrief explanation on MDN.
Applying this to border-image
Our worklet is now registered with the browser, and we can call the paint method in our main CSS file to take the place of an image URL:
border-image-source: paint(shadow, 0 0 0, 8 2 1, 8 5 3) 15;border-image-slice: 15;These are unitless values. Since we’re drawing a 1:1 image, they equate to pixels.
Adapting to display ratios
We’re almost done, but there’s one more problem to tackle.
For some of you, things might not lookquite as expected. I’ll bet you sprung for the fancy, high DPI monitor, didn’t you? We’ve encountered an issue with the device pixel ratio. The dimensions that have been passed to the paint worklet haven’t been scaled to match.
Rather than go through and scale each value manually, a simple solution is to multiply theborder-image-slice value. Here’s how to do it for proper cross-environment display.
First, let’s register a new custom property for CSS that exposeswindow.devicePixelRatio:
CSS.registerProperty({ name: "--device-pixel-ratio", syntax: "<number>", initialValue: window.devicePixelRatio, inherits: true});Since we’re registering the property, and giving it an initial value, wedon’t need to set it on:root becauseinherit: true passes it down to all elements.
And, last, we’ll multiply our value forborder-image-slice withcalc():
.foo { border-image-slice: calc(15 * var(--device-pixel-ratio));}It’s important to note that paint workletsalso have access to thedevicePixelRatio value by default. You can simply reference it in the class, e.g.console.log(devicePixelRatio).
Finished
Whew! We should now have a properly scaled image being painted in the confines of the border area!

Bonus: Apply this to a background image
I’d be remiss to not also demonstrate a solution that usesbackground-image instead ofborder-image. It’s easy to do with just a few modifications to the module we just wrote.
Since there isn’t aborder-width value to use, we’ll make that a custom property:
CSS.registerProperty({ name: "--shadow-area-width", syntax: "<integer>", initialValue: "0", inherits: false});We’ll also have to control the background color with a custom property as well. Since we’re drawinginside the content box, setting an actualbackground-color will still showbehind the background image.
CSS.registerProperty({ name: "--shadow-rectangle-fill", syntax: "<color>", initialValue: "#fff", inherits: false});Then set them on.foo:
.foo { --shadow-area-width: 15; --shadow-rectangle-fill: #efefef;}This time around,paint() gets set onbackground-image, using the same arguments as we did forborder-image:
.foo { --shadow-area-width: 15; --shadow-rectangle-fill: #efefef; background-image: paint(shadow, 0 0 0, 8 2 1, 8 5 3);}As expected, this will paint the shadow in the background. However, since background images extend into the padding box, we’ll need to adjustpadding so that text doesn’t overlap:
.foo { --shadow-area-width: 15; --shadow-rectangle-fill: #efefef; background-image: paint(shadow, 0 0 0, 8 2 1, 8 5 3); padding: 15px;}Fallbacks
As we all know, we don’t live in a world where everyone uses the same browser, or has access to the latest and greatest. To make sure they don’t receive a busted layout, let’s consider some fallbacks.
Padding fix
Padding on the parent element will condense the content box to accommodate for shadows that extend from its children.
section.parent { padding: 6px; /* size of shadow on child */}Margin fix
Margins on child elements can be used for spacing, to keep shadows away from their clipping parents:
div.child { margin: 6px; /* size of shadow on self */}Combining border-image with a radial gradient
This is a little more off the beaten path than padding or margins, butit’s got great browser support. CSS allows gradients to be used in place of images, so we can use one within aborder-image, just like how we did withpaint(). This may be agreat option as a fallback for the Paint API solution, as long as the design doesn’t requireexactly the same shadow:
Gradients can be finicky and tricky to get right, but Geoff Grahamhas a great article on using them.
div { border: 6px solid; border-image: radial-gradient( white, #aaa 0%, #fff 80%, transparent 100% ) 25%;}An offset pseudo-element
If you don’t mind some extra markup and CSS positioning, and need an exact shadow, you can also use an inset pseudo-element. Beware thez-index! Depending on the context, it may need to be adjusted.
.foo { box-sizing: border-box; position: relative; width: 300px; height: 300px; padding: 15px;}.foo::before { background: #fff; bottom: 15px; box-shadow: 0px 2px 8px 2px #333; content: ""; display: block; left: 15px; position: absolute; right: 15px; top: 15px; z-index: -1;}Final thoughts
And that, folks, is how you can use the CSS Paint API to paint just the image you need. Is it the first thing to reach for in your next project? Well, that’s for you to decide. Browser support is still forthcoming, but pushing forward.
In all fairness, it may add far more complexity than a simple problem calls for. However, if you’ve got a situation that calls for pixels putright where you want them, the CSS Paint API is a powerful tool to have.
What’s most exciting though, is the opportunity it provides for designers and developers. Drawing shadows is only a small example of what the API can do. With some imagination and ingenuity, all sorts of new designs and interactions are possible.
Further reading
- The CSS Paint API specification
- Is Houdini Ready Yet?
- CSS Paint API (Google Web Developers)
- CSS Houdini Experiments
- Another example that uses the Paint API to draw triangles and radio inputs