MakeMyPalette
← All posts
8 min read

Box shadow techniques for modern UI

A practical guide to the box-shadow property: layering, inset shadows, colored glows, elevation systems, and performance considerations.

Anatomy of box-shadow

The box-shadow property accepts one or more shadow layers, each defined by up to six values:

box-shadow: [inset] <x-offset> <y-offset> <blur> <spread> <color>;
  • x-offset — horizontal displacement. Positive pushes right, negative pushes left.
  • y-offset — vertical displacement. Positive pushes down, negative pushes up.
  • blur — the Gaussian blur radius. Zero means a sharp edge. The visible extent of the blur extends by this many pixels beyond the shadow’s geometric edge.
  • spread — expands (positive) or contracts (negative) the shadow relative to the element’s dimensions. A spread of 4px with all other offsets at zero creates a uniform outline around the box.
  • color — any CSS color. If omitted, it inherits the element’s color property (not always what you want).
  • inset — keyword that flips the shadow to the inside of the box. Useful for pressed states and inner glows.

A basic card shadow:

.card {
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
              0 2px 4px -2px rgba(0, 0, 0, 0.1);
}

This two-layer shadow is the default “medium” shadow in Tailwind CSS. The first layer provides the main soft shadow below the element; the second adds a subtle tighter shadow that sharpens the perceived edge. Most production shadow systems use two or more layers for this reason — a single layer looks flat compared to the nuanced falloff of real-world light.

Layering multiple shadows for depth

Real objects don’t cast a single uniform shadow. A piece of paper on a desk casts a tight contact shadow where it meets the surface, a medium penumbra shadow a few millimeters out, and a soft ambient shadow spread across a larger area. You can replicate this with three stacked layers:

.elevated-card {
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.07),
    0 4px 8px rgba(0, 0, 0, 0.07),
    0 12px 24px rgba(0, 0, 0, 0.07);
}

Each layer has increasing offset and blur, but a low, consistent opacity. The result looks significantly more natural than a single 0 12px 24px shadow at triple the opacity. The browser composites the layers together, so the overlap region gets slightly darker — mimicking the penumbra falloff.

Google’s Material Design elevation system is built on this principle. Their “dp” elevation values map to specific multi-layer shadow sets. You don’t need to follow Material exactly, but the concept — more layers, lower individual opacity — is universal.

Building an elevation scale

A systematic elevation scale for a design system might look like this:

:root {
  --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1),
               0 1px 2px rgba(0, 0, 0, 0.06);
  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
               0 2px 4px -2px rgba(0, 0, 0, 0.1);
  --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
               0 4px 6px -4px rgba(0, 0, 0, 0.1);
  --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
               0 8px 10px -6px rgba(0, 0, 0, 0.1);
}

Each step roughly doubles the y-offset and blur from the previous one. The negative spread values on the larger shadows keep them from bleeding too far to the sides, concentrating the shadow directly below the element. This is a common trick: using negative spread to “pull in” the shadow compensates for the large blur and prevents that washed-out look where the shadow is wider than the card itself.

Inner shadows with inset

Adding the inset keyword flips the shadow to the inside of the box. Inner shadows are essential for a few specific UI patterns:

Pressed button states: An inset shadow on :active makes a button look physically pressed. A subtle inset 0 2px 4px rgba(0, 0, 0, 0.15) combined with removing the outer shadow creates a convincing depth change.

Text input fields: Many form designs use a light inset shadow on input fields to give them a recessed appearance — distinguishing the input well from the surrounding card.

input {
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}

Neumorphism: The neumorphic (soft UI) trend pairs an outer shadow pointing down-right with an inset shadow pointing up-left — creating the illusion of a soft, extruded surface. Two layers of the same technique:

.neumorphic {
  background: #e0e0e0;
  box-shadow:
    8px 8px 16px #bebebe,
    -8px -8px 16px #ffffff;
}

Neumorphism has significant accessibility problems — the low contrast between surface and shadow makes interactive elements hard to identify — but it demonstrates the expressive range of box-shadow.

Colored shadows for glow effects

Replacing the typical black-with-low-opacity shadow with a saturated color creates a glow effect. This works well for hover states, brand accents, and CTA buttons:

.glow-button {
  background: #6366f1;
  box-shadow: 0 0 20px rgba(99, 102, 241, 0.5);
}

.glow-button:hover {
  box-shadow: 0 0 30px rgba(99, 102, 241, 0.7);
}

The zero x/y offset centers the glow around the element. The blur radius controls how far the glow extends, and the alpha channel controls intensity. Transitioning the shadow on hover with transition: box-shadow 0.2s gives a smooth pulse effect.

For a more sophisticated glow, layer a tight bright shadow inside a larger diffuse one:

.neon {
  box-shadow:
    0 0 5px rgba(99, 102, 241, 0.8),
    0 0 20px rgba(99, 102, 241, 0.4),
    0 0 60px rgba(99, 102, 241, 0.2);
}

This three-layer approach mimics the way real neon lights have a bright core, a medium glow, and a faint ambient wash.

Focus rings with box-shadow

Before outline-offset had universal support, developers used box-shadow to create focus rings with spacing around the element:

:focus-visible {
  box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.5);
  outline: none;
}

The zero blur with a spread value creates a solid ring. Today, outline with outline-offset is the preferred approach because it doesn’t interact with the element’s existing box-shadow and works on non-rectangular outlines. But the box-shadow technique is still useful when you need the focus ring to respect border-radiusoutline doesn’t round its corners in all browsers, while box-shadow does.

Performance: paint vs. composite

Changing box-shadow triggers a repaint in the browser’s rendering pipeline. It doesn’t trigger layout (reflow), so it’s cheaper than changing width or margin, but more expensive than composite-only properties like transform and opacity.

For hover transitions, this is usually fine — a repaint on a single card is fast. But if you’re animating shadows on dozens of elements simultaneously (a grid of cards all transitioning on scroll, for instance), you might notice jank on lower-end devices.

The workaround: put the shadow on a pseudo-element (::after), position it behind the card, and animate its opacity instead of the shadow itself. The shadow is always there — you’re just fading it in and out, which is a compositor-only operation:

.card {
  position: relative;
}

.card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
  opacity: 0;
  transition: opacity 0.2s;
  z-index: -1;
}

.card:hover::after {
  opacity: 1;
}

This technique avoids repainting the shadow on every frame and keeps the transition silky smooth even on mobile hardware.

Build your shadow

If you want to experiment with layered shadows interactively — adjusting offsets, blur, spread, color, and inset per layer — try the Box Shadow Generator. It outputs the full box-shadow property with one-click copy.

Ready to put this into practice?

Open the tool →