Behind the feature: shedding light on shadow spread

Starting today, you can adjust shadow spread in Figma on rectangles, ellipses, frame backgrounds, and component backgrounds, just like you can with CSS box-shadow.

I had initially planned to build this during Figma’s recent Maker Week, when we set aside time for everyone at the company to explore a project outside of their daily responsibilities. What seemed like a straightforward feature that I could tackle in a few days turned into a weeks-long journey of algorithmic ideas, W3C spec rabbit holes, and nuanced product decisions. Here, I’m sharing more about how we made tough tradeoffs about a (seemingly) simple user request.

There’s a lot that goes into creating a robust web-based design platform for teams who build products together. We provide a system that helps you develop and understand the value of complex design systems, enable real-time collaboration for teammates around the world, and even improve on old standbys like the trusty pen tool. So, you might ask: why, then, in the 958 days since a user first asked on Spectrum, is there no support for shadow spread, a basic feature of CSS’s box-shadow? Is it really so hard for us to, um, make a shadow, just…bigger?

If you ask a graphics engineer this question, the answer is a resounding well, actually. A shadow’s spread value represents the distance by which to expand or contract a shadow in all directions. To understand when this becomes complicated, we’ll start by considering how we draw shadows in the first place. Here, a few simple drop shadows:

As you can see, the shapes of the drop shadows look familiar. To create a drop shadow like this, we copy the object’s geometry, fill it with a single color, make it blurry, and render it underneath the node itself.

It might seem like rendering a shadow with a spread value could be as simple as scaling up the shadow’s geometry. It’s true that this works for the rectangle, but not so much for anything more complex—say, Figma’s logo, which is full of holes:

If we consider the definition of a shadow spread—expanding the shadow by a certain number of pixels outward (or inward) from the geometry in every direction—we’d want something more like this:

But no one told me that before I decided I’d try to tackle shadow spread one afternoon during Figma’s Maker Week in May. I bushwhacked naïvely through the “wrong” approach during Maker Week, realized the problem, and then charged ahead, determined to figure it out. This isn’t rocket science, I thought. We can figure out how to render shadows of 2D shapes.

It’s true that there are a few interesting algorithmic ways to go about this, but none of these slotted neatly into our existing rendering system. It’s also possible to approach this in a non-algorithmic way—by taking advantage of strokes to emulate shadows with spread distances—but I quickly realized that wasn’t an option either; we handle certain vertex angles differently in strokes than you’d want for shadow spread, and we don’t have stroke generation code in our prototype renderer. Somehow we needed to find a way to make this work without adding tons of complex geometry code to two different rendering codebases.

A framework for prioritization

If there’s one thing I love more than debugging rendering bugs, it’s reading specs about internet technologies. (Ask me all the weird things I learned about GIF89a when implementing GIF support in Figma last year.) I began to interrogate the assumptions we’d held about shadow spreads. We know Figma users today implement workarounds and include separately maintained documentation for developer handoff when shadow spread values are involved. If we’re building shadow spread because we want to reduce friction during developer handoff, CSS should guide our constraints. Do we really need to draw perfect shadow spreads for hole-y Figma logos? Can we even do that in CSS?!

In fact, we can't. The thing about box-shadow is that it only works to render shadows of boxes (and other box-like things, which include ellipses, if you get the corner radii right). box-shadow will not render a shadow of the Figma logo as a copy-of-the-logo-but-blobby; rather, it will render a box.

(An aside: at every step in this process, someone told me, “actually, spread values are supported in filter: drop-shadow(),” and pointed me to an MDN page that mistakenly implied that spread values were included in the spec and were simply not supported by browsers yet. Unfortunately, this was never true, as explicitly noted in the W3C spec. We know! We’re sad too.)

Having discussed with our designer advocates, who were sure a huge percentage of use cases would be covered just by having shadow spread available on rectangles and ellipses, and further steeled by the idea that CSS compliance should motivate our decisions here, we determined that it doesn’t matter whether we can render a Figma-shaped logo. We decided to ruthlessly prioritize: we’d at least do what CSS can do.

To make this happen, we decided to implement CSS-like shadow spread parameters only on shapes where box-shadow would apply: rectangles, ellipses, frame backgrounds, and component backgrounds. This seemed achievable by doing the simple thing, more or less—generating a bigger or smaller version of the original node. It’s not quite as straightforward as stretching the node, as this would break down on things like rounded corners. Still, it’s easy enough to generate new rectangle geometries in both of our rendering engines.

Top: a shadow generated simply by stretching the object’s geometry; bottom: a shadow created by generating a new rounded rectangle

Hiccups along the way

Of course, nothing was as straightforward as we expected. There were several hitches in this plan: how would we generate correct ellipses? (A true spread shadow for an ellipse would no longer be definitionally elliptical; our ellipse generation code does not generate non-ellipses, and simple transforms on an ellipse in either direction maintain its elliptical properties.) How would we render rounded corners when a spread distance was applied to a rounded rectangle? (The W3 spec defines both a general rule about transforming corner radii, and a specific formula to use for large spread values.) How would we render shadow spread on stroke-only nodes, an undefined behavior in CSS?

We solved a few of these problems with the tried-and-true science of mashing buttons in a CodePen and seeing what browsers do. Interestingly, browsers don’t implement elliptical shadows with spread by generating spread blobs; they just do the easy thing of generating a larger ellipse. Having decided to mostly do what CSS does, we do that too.

The effect becomes more pronounced as an ellipse’s axes diverge:

More surprisingly, after following the specific W3C cubic rule for corner radii of shadows with large spread values (a carefully considered rule!), comparing our results with a quick CodePen indicated that browsers, as of this publication, don't implement this at all. To create shadow spread for a rounded rectangle, browsers—and now, Figma—always simply add or subtract the spread value and the original corner radius.

But CodePen would be no help in defining shadow spread behaviors for stroke-only nodes, as our shadow approach already diverges significantly from CSS here. Even fully transparent fills in CSS factor into and mask their own shadows (though not other shadows); Figma takes an approach closer to physically-based rendering, allowing the user to see shadows through transparent and translucent fills, and not including invisible geometry in shadows. 

Below, the same rectangles (zero-opacity fills, with strokes and drop shadows) in CSS (left) and Figma (right):

While it’s easy to know how to render a shadow for a node with a fill (below left), you might imagine interpreting shadow spread for a stroke in several ways:

A. Outset the stroke by spread, leaving the stroke constant B. Add spread to the outside of the stroke width C. Add spread to the stroke width, centered so that it's distributed on either side D. Add spread to each edge of the stroke width, ultimately adding 2 * spread to the stroke width  

After considering the options, we arrived at D: we thought that when you toggle the visibility of the object’s fill, the shadow’s outer footprint should stay the same, which eliminated C. Of the remaining options, D seemed most in keeping with the idea of a shadow spread: a shadow, extruded along every point by spread.

Building a new feature isn’t always as simple as it seems. When interpreting a feature request, it’s important to think about the motivations behind that request, and instructive to consider the tradeoffs made by those before us. In this case, after navigating a winding journey of investigations and explorations, we’re excited to ship a widely-requested feature that hopefully makes designers’ and developers’ lives easier. Check out our playground file to see what’s possible with shadow spread!