CSS guide
CSS Box Shadow Guide
How box-shadow works, how to layer shadows for realistic elevation, when to use drop-shadow() instead, and how to animate shadows and build focus rings without hurting performance.
box-shadow is one of the most used properties in interface design: cards, dropdowns, modals, buttons and focus states all rely on it. It is also one of the easiest to get wrong — a single heavy black shadow looks dated, and animating shadows naively can make a page stutter. This guide covers the syntax, the techniques that produce natural-looking depth, and the performance details.
Syntax
box-shadow: [inset] <offset-x> <offset-y> [<blur-radius>] [<spread-radius>] [<color>];
/* Examples */
box-shadow: 0 2px 4px rgb(0 0 0 / 0.12);
box-shadow: 0 0 0 3px #6366f1; /* solid ring: no blur, 3px spread */
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
box-shadow: none;- offset-x / offset-y move the shadow. Positive values go right and down. Most UI shadows use
0horizontally and a positive vertical offset, mimicking light from above. - blur-radius (default
0) softens the edge. Negative values are invalid. - spread-radius (default
0) grows or shrinks the shadow before blurring. A negative spread keeps a blurred shadow tucked under the element, which is useful for subtle bottom shadows. - color defaults to
currentColor. Always specify it explicitly. - inset draws the shadow inside the border box, for pressed buttons and recessed inputs.
The shadow follows border-radius, does not affect layout (it doesn't take up space or move neighbouring elements), and is clipped by an ancestor with overflow: hidden. That last point explains many “my shadow is cut off” bugs: add padding to the clipping container or move the overflow rule.
Layered shadows for realistic elevation
Real objects cast a sharp, dark shadow close to the surface and a wide, faint one further away. A single box-shadow can only approximate one of those. Comma-separated shadows let you stack both; the first in the list is painted on top.
/* Single shadow: looks flat and smudgy */
.card { box-shadow: 0 10px 30px rgb(0 0 0 / 0.3); }
/* Layered: a tight contact shadow plus a soft ambient one */
.card {
box-shadow:
0 1px 2px rgb(15 23 42 / 0.06),
0 4px 8px rgb(15 23 42 / 0.06),
0 16px 32px rgb(15 23 42 / 0.08);
}A practical system defines a few elevation levels as custom properties and reuses them everywhere, so a dropdown always sits “above” a card:
:root {
--shadow-color: 220 40% 15%;
--shadow-sm: 0 1px 2px hsl(var(--shadow-color) / 0.08);
--shadow-md:
0 1px 2px hsl(var(--shadow-color) / 0.06),
0 4px 12px hsl(var(--shadow-color) / 0.08);
--shadow-lg:
0 2px 4px hsl(var(--shadow-color) / 0.06),
0 12px 24px hsl(var(--shadow-color) / 0.1),
0 24px 48px hsl(var(--shadow-color) / 0.1);
}
.card { box-shadow: var(--shadow-sm); }
.dropdown { box-shadow: var(--shadow-md); }
.modal { box-shadow: var(--shadow-lg); }As elevation increases, increase the offset and blur together, and keep each layer's opacity low. The box shadow generator lets you stack layers and preview them on light and dark backgrounds.
Inset shadows
The inset keyword flips the shadow inside the element, making a surface look recessed rather than raised. It is the natural choice for text inputs, pressed button states, wells and progress-bar tracks. Inset and outer shadows can be combined in the same declaration:
/* Recessed input */
.input {
box-shadow: inset 0 1px 2px rgb(15 23 42 / 0.08);
}
/* Button that looks pressed while active */
.button:active {
box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.15);
transform: translateY(1px);
}
/* Subtle top highlight plus outer shadow on a raised button */
.button {
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.25),
0 1px 2px rgb(0 0 0 / 0.2);
}Inset shadows are painted above the background but below the content, so they work on elements with background images or gradients too.
Shadows as borders
A shadow with no offset, no blur and a spread value behaves like a border that takes up no space. That makes it useful for hover and selected states that would otherwise cause layout shift when a real border appears:
.option { box-shadow: 0 0 0 1px #e2e8f0; }
.option[aria-selected="true"] { box-shadow: 0 0 0 2px #4f46e5; }Unlike border, multiple shadow “borders” can be stacked to draw double or triple outlines. The trade-off is that they are clipped by overflow: hidden on a parent and disappear in forced-colors mode, so don't use them for boundaries that users must see to operate a control. The border generator covers the real-border alternatives.
Choosing shadow colors
- Avoid pure black at high opacity.
rgb(0 0 0 / 0.5)looks heavy and grey on colored surfaces. - Tint the shadow toward the background hue. On a warm beige page, a brown-tinted shadow looks natural; on a blue-grey UI, use a dark navy. A colored button can cast a shadow in a darker, desaturated version of its own color.
- Keep alpha low — 0.04 to 0.15 per layer — and let the layers add up.
If you need to convert a brand hex into an rgb() or hsl() triplet for alpha control, the hex to RGB and RGB to HSL converters help.
box-shadow vs filter: drop-shadow()
box-shadow always follows the element's box (respecting border-radius). The drop-shadow() filter follows the rendered alpha of the element, including transparent PNG and SVG shapes, clipped elements and children that overflow.
/* Shadow follows the logo's actual shape */
.logo { filter: drop-shadow(0 4px 6px rgb(0 0 0 / 0.2)); }
/* A tooltip with a CSS triangle: drop-shadow covers the arrow too */
.tooltip { filter: drop-shadow(0 2px 4px rgb(0 0 0 / 0.15)); }box-shadow | drop-shadow() | |
|---|---|---|
| Shape | Element box | Visible pixels |
| Spread | Yes | No |
| Inset | Yes | No |
| Multiple layers | Comma-separated | Chain several drop-shadow() functions |
| Side effects | None | Creates a stacking context and containing block for fixed descendants |
For text, use text-shadow instead — see the text shadow generator. For combining several filters, the filter generator previews the result.
Animating shadows without jank
Changing box-shadow forces the browser to repaint the shadow every frame. On a single button that is fine; on a grid of 30 cards with large blurred shadows it can drop frames, especially on mobile. The well-known fix is to render the “raised” shadow once on a pseudo-element and animate only its opacity, which the compositor can handle:
.card {
position: relative;
box-shadow: var(--shadow-sm);
transition: transform 200ms ease;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: var(--shadow-lg);
opacity: 0;
transition: opacity 200ms ease;
pointer-events: none;
}
.card:hover { transform: translateY(-2px); }
.card:hover::after { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.card, .card::after { transition: none; }
}Combine it with a small transform: translateY() for a convincing lift. The transform generator helps fine-tune that part.
Shadows in dark mode
Dark shadows on a dark background barely register. In dark themes, depth is usually communicated by making raised surfaces lighter, with shadows playing a supporting role:
@media (prefers-color-scheme: dark) {
:root {
--surface-1: #0f172a;
--surface-2: #1e293b; /* raised = lighter */
--shadow-md:
0 1px 2px rgb(0 0 0 / 0.4),
0 8px 24px rgb(0 0 0 / 0.5);
}
.card {
background: var(--surface-2);
box-shadow: var(--shadow-md), inset 0 1px 0 rgb(255 255 255 / 0.05);
}
}Higher shadow opacity and a faint light inset along the top edge help elevated surfaces separate from the page.
Focus rings with box-shadow
A zero-blur shadow with a spread value draws a crisp ring that follows border-radius and doesn't shift layout, which made it a popular focus indicator:
.button:focus-visible {
outline: 2px solid transparent; /* stays visible in Windows forced-colors mode */
outline-offset: 2px;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #4f46e5;
}The double ring (white then brand color) stays visible on both light and dark backgrounds. Keep the transparent outline: in forced-colors mode box-shadows are removed, but the outline is repainted in a system color. Today, outline itself follows border-radius in all major browsers, so a plain outline: 2px solid #4f46e5; outline-offset: 2px; is often the simplest accessible choice. Whichever you choose, the ring should have at least 3:1 contrast with adjacent colors.
Common mistakes
- One large, dark shadow instead of several light layers.
- Shadow clipped by a parent's
overflow: hidden. - Transitioning
box-shadowon many elements at once instead of a pseudo-element's opacity. - Using
box-shadowon a transparent PNG and expecting it to follow the image shape. - Removing outlines for focus without providing a visible replacement.
Shadows pair well with gradients for buttons and hero sections — see the CSS gradient guide, or build a full button style with the button generator.