Designing Beautiful Shadows in CSS
In my humble opinion, the best websites and web applications have a tangible “real” quality to them. There are lots of factors involved to achieve this quality, but shadows are a critical ingredient.
When I look around the web, though, it’s clear that most shadows aren’t as rich as they could be. The web is covered in fuzzy grey boxes that don’t really look much like shadows.
In this tutorial, we’ll learn how to transform typical box-shadows into beautiful, life-like ones:
This tutorial is intended for developers who are comfortable with the basics of CSS. Some knowledge around box-shadow , hsl() colors, and CSS variables is assumed.
We’ll get to the fun CSS trickery soon, I promise. But first, I wanna take a step back and talk about why shadows exist in CSS, and how we can use them to maximum effect. Shadows imply elevation, and bigger shadows imply more elevation. If we use shadows strategically, we can create the illusion of depth, as if different elements on the page are floating above the background at different levels. Here’s an example. Drag the «Reveal» slider to see what I mean: I want the applications I build to feel tactile and genuine, as if the browser is a window into a different world. Shadows help sell that illusion. There’s also a tactical benefit here as well. By using different shadows on the header and dialog box, we create the impression that the dialog box is closer to us than the header is. Our attention tends to be drawn to the elements closest to us, and so by elevating the dialog box, we make it more likely that the user focuses on it first. We can use elevation as a tool to direct attention. When I use shadows, I do it with one of these purposes in mind. Either I want to increase the prominence of a specific element, or I want to make my application feel more tactile and life-like. In order to achieve these goals, though, we need to take a holistic view of the shadows in our application.
For a long time, I didn’t really use shadows correctly 😬. When I wanted an element to have a shadow, I’d add the box-shadow property and tinker with the numbers until I liked the look of the result. Here’s the problem: by creating each shadow in isolation like this, you’ll wind up with a mess of incongruous shadows. If our goal is to create the illusion of depth, we need each and every shadow to match. Otherwise, it just looks like a bunch of blurry borders: In the natural world, shadows are cast from a light source. The direction of the shadows depends on the position of the light: In general, we should decide on a single light source for all elements on the page. It’s common for that light source to be above and slightly to the left: If CSS had a real lighting system, we would specify a position for one or more lights. Sadly, CSS has no such thing. Instead, we shift the shadow around by specifying a horizontal offset and a vertical offset. In the image above, for example, the resulting shadow has a 4px vertical offset and a 2px horizontal offset. Here’s the first trick for cohesive shadows: every shadow on the page should share the same ratio. This will make it seem like every element is lit from the same light source.
You may be wondering why I suggest using the same ratio for every element. Wouldn’t each element need to have its own ratio, since each element will have a unique position relative to the light source? This is true if the light source is nearby, like people huddled around a campfire. But if the light source is far away, like the sun, those differences are negligible. Everything will cast a shadow at the same angle. As a matter of practicality, I choose to have all shadows share the same angle, because trying to calculate unique angles for each element sounds like way too much hassle for me. 😅
(I’m also increasing the size of the card, for even more realism. In practice, it can be easier to skip this step.)
There are probably complex mathematical reasons for why these things happen, but we can leverage our intuition as humans that exist in a lit world.
If you’re in a well-lit room, press your hand against your desk (or any nearby surface) and slowly lift up. Notice how the shadow changes: it moves further away from your hand (larger offset), it becomes fuzzier (larger blur radius), and it starts to fade away (lower opacity). If you’re not able to move your hands, you can use reference objects in the room instead. Compare the different shadows around you.
Because we have so much experience existing in environments with shadows, we don’t really have to memorize a bunch of new rules. We just need to apply our intuition when it comes to designing shadows. Though this does require a mindset shift; we need to start thinking of our HTML elements as physical objects.
The box-shadow property represents the light source’s position using horizontal and vertical offsets. To ensure consistency, each shadow should use the same ratio between these two numbers.
As an element gets closer to the user, the offset should increase, the blur radius should increase, and the shadow’s opacity should decrease.
Modern 3D illustration tools like Blender can produce realistic shadows and lighting by using a technique known as raytracing.
In raytracing, hundreds of beams of lights are shot out from the camera, bouncing off of the surfaces in the scene hundreds of times. This is a computationally-expensive technique; it can take minutes to hours to produce a single image!
Web users don’t have that kind of patience, and so the box-shadow algorithm is much more rudimentary. It creates a box in the shape of our element, and applies a basic blurring algorithm to it.
As a result, our shadows will never look photo-realistic, but we can improve things quite a bit with a nifty technique: layering.
Instead of using a single box-shadow, we’ll stack a handful on top of each other, with slightly-different offsets and radiuses:
Code Playground
style>.traditional.boxbox-shadow:0 6px 6px hsl(0deg 0% 0% / 0.3);>.layered.boxbox-shadow:0 1px 1px hsl(0deg 0% 0% / 0.075),0 2px 2px hsl(0deg 0% 0% / 0.075),0 4px 4px hsl(0deg 0% 0% / 0.075),0 8px 8px hsl(0deg 0% 0% / 0.075),0 16px 16px hsl(0deg 0% 0% / 0.075);>style>section class="wrapper">div class="traditional box">div>div class="layered box">div>section>
By layering multiple shadows, we create a bit of the subtlety present in real-life shadows. This technique is described in detail in Tobias Ahlin’s wonderful blog post, “Smoother and Sharper Shadows with Layered box-shadow”. Later in this blog post, I’ll share some tools for coming up with these values programmatically!
Layered shadows are undeniably beautiful, but they do come with a cost. If we layer 5 shadows, our device has to do 5x more work! This isn’t as much of an issue on modern hardware, but it can slow rendering down on older inexpensive mobile devices. As always, be sure to do your own testing! In my experience, layered shadows don’t affect performance in a significant way, but I’ve also never tried to use dozens or hundreds at the same time. Also, it’s probably a bad idea to try animating a layered shadow.
So far, all of our shadows have used a semi-transparent black color, like hsl(0deg 0% 0% / 0.4) . This isn’t actually ideal. When we layer black over our background color, it doesn’t just make it darker; it also desaturates it quite a bit. Compare these two boxes: