Painting shadows inside the border box with Houdini

box-shadow is one of those CSS properties developers reach for constantly, but it comes with an awkward constraint: shadows occupy no space in the box model. They spread outward from the element’s border box and get clipped the moment an ancestor has overflow: hidden. The usual fixes — extra padding or margins — work, but they shift layout in ways you have to babysit.

The CSS Paint API, part of the Houdini family of low-level browser APIs, offers a different route. Instead of trying to reserve space for a shadow, you can paint the shadow directly into the element’s border-image area. Because border-width contributes to the element’s total size, the shadow becomes part of the element itself rather than an overflow artifact.

Support status and setup

The CSS Paint API is a W3C Candidate Recommendation and works out of the box in Chrome and Edge. Safari has it behind a flag, Firefox is still evaluating it, and a polyfill exists for other browsers (though IE11 is out of luck). Even in Chromium, passing arguments to the paint() function requires enabling experimental web platform features, so treat the technique as a preview rather than a production tool.

A paint worklet is a JavaScript module that draws an image the browser can use wherever it expects an image. The image is generated at runtime and can be plugged into border-image or background-image. The drawing happens on a canvas-like context, so anyone who has worked with the HTML <canvas> element will recognize the API.

Here is the approach in four steps:

  1. Set up the HTML and CSS for the target element
  2. Create a module that defines the painting logic
  3. Register the module as a paint worklet
  4. Reference the worklet from CSS with paint()

Start with a simple square element:

<section>
  <div class="foo"></div>
</section>

And its styles:

.foo {
  border: 15px solid #efefef;
  box-sizing: border-box;
  height: 300px;
  width: 300px;
}

Writing the paint class

Worklets require HTTPS; they will not run over plain HTTP. The module itself is a file that calls registerPaint() with two arguments: a name for the worklet and a class containing the drawing logic.

registerPaint(
  "shadow",
  class {}
);

The class needs a paint() method, plus two optional getters that control what data flows in: inputProperties and inputArguments.

registerPaint(
  "shadow",
  class {
    static get inputProperties() {
      return [];
    }
    static get inputArguments() {
      return [];
    }
    paint(context, size, props, args) {}
  }
);

Declaring input properties

inputProperties is a getter returning an array of CSS property names the paint method will read. You can list both custom properties and standard ones. In this example the worklet pulls --shadow-colors, background-color, and border-top-width. Note that shorthand properties should be expanded; the code uses the longhand border-width property and assumes the border is uniform on all sides.

static get inputProperties() {
  return ["--shadow-colors", "background-color", "border-top-width"];
}

Passing arguments

inputArguments works differently. Rather than listening for properties that might be changed elsewhere in the cascade, arguments are passed explicitly in the paint() function call in your CSS. Another distinction: arguments are not named. Inside the paint method they arrive as an array, and inputArguments only declares their types.

The shadow worklet takes three arguments: a list of X offsets, a list of Y offsets, and a list of blur radii, each a space-separated sequence of integers.

static get inputArguments() {
  return ["<integer>+", "<integer>+", "<integer>+"];
}

You could skip inputArguments and rely on custom properties set directly on the element, but then careful namespacing is required to prevent inherited custom properties from leaking into the drawing.

The paint method

Four parameters are passed implicitly to paint(): the context object, a geometry object with width and height, a properties map built from inputProperties, and the arguments array. The context behaves mostly like a 2D canvas context, except you cannot read pixels back or render text.

paint(ctx, geom, props, args) {}

The geometry gives the full element size, but since the shadow is being drawn into the border area, the code compensates for the border thickness:

const width = (geom.width - borderWidth * 2);
const height = (geom.height - borderWidth * 2);

Properties come through as a map. Use get() for a single value and getAll() for a list. The --shadow-colors property is a space-separated list of colors, which can be converted directly to an array:

const borderWidth = props.get("border-top-width").value;
const shadowColors = props.getAll("--shadow-colors");

The worklet paints a background rectangle using the same fill color as the element itself:

ctx.fillStyle = props.get("background-color").toString();

Arguments are CSSStyleValue instances. To iterate through them, convert each to a string and split on whitespace:

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

Since each shadow color applies to exactly one shadow, loop through the color array with forEach():

shadowColors.forEach((shadowColor, index) => { 
});

Using the loop index, pull the matching X, Y, and blur values from the argument lists:

shadowColors.forEach((shadowColor, index) => {
  ctx.shadowOffsetX = xArray[index];
  ctx.shadowOffsetY = yArray[index];
  ctx.shadowBlur = blurArray[index];
  ctx.shadowColor = shadowColor.toString();
});

Finally fillRect() draws each shadow layer. The X and Y positions are offset by the border width so that the resulting image is clipped to contain only the shadow surrounding the inner 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);
});

A simpler alternative is to draw a single rectangle and apply a canvas drop-shadow filter. That works in Chrome, Edge, and Firefox, but not Safari.

Wiring it up

Back in the main JavaScript file, register the module as a paint worklet:

CSS.paintWorklet.addModule("https://codepen.io/steve_fulghum/pen/bGevbzm.js");
https://codepen.io/steve_fulghum/pen/BazexJX

Optionally, register the custom property so the browser knows --shadow-colors is a list of colors rather than an arbitrary string:

CSS.registerProperty({
  name: "--shadow-colors",
  syntax: "<color>+",
  initialValue: "black",
  inherits: false
});

In browsers that lack the Properties and Values API, the paint worklet can still read unregistered custom properties, but they arrive as raw strings and require manual parsing.

With the worklet registered, reference it in CSS where an image would normally go:

border-image-source: paint(shadow, 0 0 0, 8 2 1, 8 5 3) 15;
border-image-slice: 15;

The values are unitless but, because the image is 1:1, they map one-to-one to pixels.

Handling high-DPI displays

On a high-density screen the worklet receives unscaled dimensions, so the output can look wrong. The simplest correction is to multiply the border-image-slice value by the device pixel ratio. Register a custom property that exposes window.devicePixelRatio:

CSS.registerProperty({
  name: "--device-pixel-ratio",
  syntax: "<number>",
  initialValue: window.devicePixelRatio,
  inherits: true
});

Because the property is registered with inherit: true, it cascades to every element without needing to be set on :root:

.foo {
  border-image-slice: calc(15 * var(--device-pixel-ratio));
}

Note that paint worklets can also access devicePixelRatio directly from the global scope inside the class.

Using a background image instead

The same worklet can feed background-image with a few adjustments. There is no border width to latch onto, so the shadow offset becomes a custom property:

CSS.registerProperty({
  name: "--shadow-area-width",
  syntax: "<integer>",
  initialValue: "0",
  inherits: false
});

The background color also needs to be a custom property, since a real background-color sits behind the painted image and would still show through:

CSS.registerProperty({
  name: "--shadow-rectangle-fill",
  syntax: "<color>",
  initialValue: "#fff",
  inherits: false
});

Set both properties on the element:

.foo {
  --shadow-area-width: 15;
  --shadow-rectangle-fill: #efefef;
}

Then call the worklet from background-image with the same arguments:

.foo {
  --shadow-area-width: 15;
  --shadow-rectangle-fill: #efefef;
  background-image: paint(shadow, 0 0 0, 8 2 1, 8 5 3);
}

Because background images extend into the padding box, add padding so the content does not overlap the painted shadow:

.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 for older browsers

Until Paint API support is universal, plan for a graceful degradation path. Since border-image and background-image both fall back cleanly to earlier declarations, anything defined before the paint() line remains in force in browsers that do not recognize it.

Additional fallback strategies include:

  • Adding padding on the parent element so child shadows are not clipped
  • Using margins on child elements to keep their shadows away from an ancestor’s clipping boundary
section.parent {
  padding: 6px; /* size of shadow on child */
}
div.child {
  margin: 6px; /* size of shadow on self */
}

A radial gradient in a border-image offers broad browser support and approximates a soft shadow effect without JavaScript:

div {
  border: 6px solid;
  border-image: radial-gradient(
    white,
    #aaa 0%,
    #fff 80%,
    transparent 100%
  )
  25%;
}

For an exact shadow that works everywhere, an inset pseudo-element with positioning is a reliable alternative, though z-index may need attention:

.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;
}

What the technique is really for

Painting shadows into the border box is a demonstration more than a daily driver; the added complexity only pays off when you need pixel-level control over an element’s visual output. The larger point is that the CSS Paint API moves the browser toward programmable styling. Generating layered shadows is one small use case among many that will open up as browser support matures and input arguments leave the experimental-flag stage.