CSS Multi-Layer Shadows: From box-shadow Syntax to Realistic Light Effects
CSS Multi-Layer Shadows: From box-shadow Syntax to Realistic Light Effects#
A product manager asked for “more depth” on a card component. I added a box-shadow. “Not premium enough,” he said. After studying Google Material Design’s shadow system, I realized shadows are more complex than they appear.
The Full box-shadow Syntax#
Let’s start with the spec:
box-shadow: [inset] <offset-x> <offset-y> <blur-radius> <spread-radius> <color>;
Six parameters. The most overlooked one is spread-radius:
- offset-x/offset-y: Shadow offset. Positive = right/bottom, negative = left/top
- blur-radius: Blur amount. 0 = sharp edge
- spread-radius: Positive expands shadow, negative shrinks it
- inset: Inner shadow, useful for input focus states
A simple card shadow:
.card {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
Notice the -1px. That’s the spread value. Why negative? It keeps the shadow tighter, preventing too much overflow.
Why Multi-Layer Shadows Look More Realistic#
Single-layer shadows have a problem: single light source, no depth.
Real-world lighting involves multiple sources:
- Direct key light (hard shadow)
- Ambient scatter (soft shadow)
- Ground reflection (fill light)
Here’s how to simulate this with CSS:
.realistic-shadow {
box-shadow:
/* Layer 1: Hard shadow from key light */
0 1px 2px rgba(0, 0, 0, 0.15),
/* Layer 2: Soft shadow from ambient */
0 4px 8px rgba(0, 0, 0, 0.10),
/* Layer 3: Diffuse shadow from reflection */
0 8px 16px rgba(0, 0, 0, 0.05);
}
The pattern:
- Layer 1: Small offset, small blur, high opacity → sharp edge
- Layer 2: Medium offset, medium blur, medium opacity → transition
- Layer 3: Large offset, large blur, low opacity → soft diffusion
Material Design Shadow Levels#
Google’s Material Design defines 5 elevation levels:
/* Elevation 1 - Card hover */
.elevation-1 {
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.06),
0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
/* Elevation 2 - Popover menu */
.elevation-2 {
box-shadow:
0 3px 5px -1px rgba(0, 0, 0, 0.06),
0 6px 10px -1px rgba(0, 0, 0, 0.1);
}
/* Elevation 3 - Floating action button */
.elevation-3 {
box-shadow:
0 7px 8px -4px rgba(0, 0, 0, 0.06),
0 12px 17px -5px rgba(0, 0, 0, 0.1);
}
The logic is clear:
- Larger shadow = object further from ground
- Decreasing opacity: darker near, lighter far
- Spread often negative to control shadow spread
Special Uses for Inset Shadows#
The inset keyword places shadows inside the element:
/* Input focus effect */
.input:focus {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Button press effect */
.button:active {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
}
/* Embossed text effect */
.embossed {
box-shadow:
inset 1px 1px 0 rgba(255, 255, 255, 0.3),
inset -1px -1px 0 rgba(0, 0, 0, 0.2);
}
One clever use: simulating recessed or raised effects. By layering white and black inset shadows, you can create realistic embossed textures.
The Performance Trap of Shadow Animations#
Want to animate shadows? Be careful:
/* ❌ Triggers repaint every frame */
.card:hover {
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
transition: box-shadow 0.3s;
}
Animating box-shadow causes repaints on every frame. Expensive.
Better approach: Use a pseudo-element + opacity:
.card {
position: relative;
}
.card::after {
content: '';
position: absolute;
inset: 0;
opacity: 0;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
transition: opacity 0.3s;
}
.card:hover::after {
opacity: 1;
}
Opacity animations are handled by the compositor, no repaints needed. Much better performance.
Building a Shadow Generator#
To make shadow debugging easier, I built: CSS Shadow Generator
Features:
- Multi-layer shadow stacking
- Real-time preview
- One-click CSS copy
- Inset shadow toggle
The data structure is straightforward:
interface ShadowLayer {
offsetX: number
offsetY: number
blur: number
spread: number
color: string
inset: boolean
}
Generating the CSS string:
const shadowValue = layers.map(layer =>
`${layer.inset ? 'inset ' : ''}${layer.offsetX}px ${layer.offsetY}px ${layer.blur}px ${layer.spread}px ${layer.color}`
).join(', ')
The key is join(', '). CSS multi-values are comma-separated. Each layer is configured independently, then merged into one property.
Practical Tips Summary#
| Scenario | Recommended Config |
|---|---|
| Card default | 0 2px 4px rgba(0,0,0,0.1) |
| Card hover | 0 8px 16px rgba(0,0,0,0.15) |
| Modal/Dialog | 0 20px 40px rgba(0,0,0,0.2) |
| Input focus | inset 0 2px 4px rgba(0,0,0,0.1) |
| Button press | inset 0 2px 4px rgba(0,0,0,0.2) |
More layers doesn’t mean better. 2-3 layers is enough. The key is decreasing opacity and increasing blur radius to simulate real light falloff.
Related tools: CSS Gradient Generator | CSS Variable Generator