Lesson 22 of 25

CSS Shadows & Effects

Reading a box-shadow, Value by Value

box-shadow takes up to five parts in a fixed order: horizontal offset, vertical offset, blur radius, spread radius, and colour. The offsets say where the shadow sits relative to the box. Blur softens its edge — zero gives a hard-edged copy of the box. Spread grows or shrinks the shadow before blurring, so a negative spread pulls it in and makes a large shadow look tighter. The keyword inset at the start turns it inwards, so the shadow falls inside the box like a pressed-in well.

Most shadows that look wrong look wrong for the same three reasons. The horizontal offset is not zero, which implies light coming from the side and immediately reads as artificial — real interfaces almost always light from directly above, so keep x at 0 and use a positive y. The blur is too small relative to the offset, giving a hard band instead of a soft falloff; a blur roughly two to three times the vertical offset looks natural. And the colour is too strong: a shadow at 40% black looks like a sticker, while the same shadow at 8 to 12% reads as depth.

Note that a shadow does not take up any layout space. Like an outline, it is painted outside the box and nothing around it moves — so adding one on hover never shifts the page. It also follows border-radius automatically, which is why a shadow on a rounded card curves correctly with no extra work.

Example
/* x  y   blur spread colour */
.card { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); }

/* Hard-edged: no blur, so it is a solid offset copy */
.sticker { box-shadow: 4px 4px 0 #d1039e; }

/* Negative spread tightens a large soft shadow */
.lifted { box-shadow: 0 16px 32px -12px rgba(0, 0, 0, 0.3); }

/* Inset: pressed in rather than raised */
.well { box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.12); }

/* A "border" that occupies no space and follows the radius */
.chip { box-shadow: 0 0 0 2px #d1039e; }
  • Order: x-offset y-offset blur spread colour
  • Keep the horizontal offset at 0 — light comes from above
  • Blur roughly two to three times the vertical offset looks natural
  • Low opacity (around 8–15% black) reads as depth; high opacity reads as a sticker
  • Shadows take no layout space and follow border-radius
  • inset turns the shadow inwards

Elevation: Layering Shadows Into a System

A single shadow is a reasonable approximation. Two or three layered shadows look considerably more convincing, because real shadows are not one uniform smudge — there is a tight, darker contact shadow immediately under an object and a wider, fainter ambient one spreading further out. Listing several shadows separated by commas gives you exactly that, with the first one painted on top.

More useful than any individual shadow is treating them as a small scale. Pick three or four levels of elevation, store them as custom properties, and use only those. Level one for a resting card, level two for a hovered card, level three for a dropdown, level four for a modal. The result is that everything on your page agrees about how far off the surface it is, which is most of what makes an interface look designed rather than assembled.

One thing that quietly signals amateur work: shadows that are the same darkness in dark mode as in light mode. A black shadow on a near-black background is invisible, so a dark theme loses all its depth cues. The usual answers are to raise the surface colour instead — a slightly lighter grey card on a darker background — or to use a much stronger, larger shadow. Either way it needs to be a deliberate decision inside your dark theme block.

Example
/* Two layers: a tight contact shadow and a soft ambient one */
.card {
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.06),
    0 8px 24px rgba(0, 0, 0, 0.08);
}

/* An elevation scale, defined once */
:root {
  --shadow-1: 0 1px 2px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.06);
  --shadow-2: 0 2px 4px rgba(0,0,0,0.06), 0 8px 20px rgba(0,0,0,0.10);
  --shadow-3: 0 4px 8px rgba(0,0,0,0.08), 0 16px 40px rgba(0,0,0,0.14);
}

.card         { box-shadow: var(--shadow-1); }
.card:hover   { box-shadow: var(--shadow-2); }
.dropdown     { box-shadow: var(--shadow-2); }
.modal        { box-shadow: var(--shadow-3); }

/* Depth in dark mode comes from surface colour, not from black shadows */
@media (prefers-color-scheme: dark) {
  :root {
    --shadow-1: 0 1px 2px rgba(0,0,0,0.5);
    --surface:  #23232b;   /* lighter than the page behind it */
  }
}
Notes
  • A tinted shadow often looks better than a grey one. Using a very dark version of the page's own hue — or of the brand colour, at low opacity — makes the shadow sit in the palette instead of looking like a grey smear laid over it.

text-shadow, filter and backdrop-filter

text-shadow works like box-shadow with one difference: there is no spread value, so it takes offsets, a blur and a colour. Its most defensible use is readability — a subtle dark shadow behind white text over a photograph keeps the text legible where the image happens to be bright. Even so, a semi-transparent overlay behind the text, as in the backgrounds lesson, usually looks cleaner. Stacking several coloured text shadows produces a glow, which is fun and rarely appropriate outside a hero heading.

filter applies a graphical effect to the whole element and everything inside it: blur(), grayscale(), brightness(), contrast(), saturate(), opacity() and drop-shadow(). You can chain several by separating them with spaces. grayscale(1) on a logo wall with the colour restored on hover is a classic, and it costs one declaration instead of two sets of images.

filter: drop-shadow() deserves special mention because it does something box-shadow cannot: it follows the actual shape of the content, including transparency. A shadow on a PNG logo with a transparent background or on an SVG icon traces the artwork itself, while box-shadow would draw a rectangle around it. If a shadow on an image looks like a box because it is a box, this is the property you wanted.

backdrop-filter filters whatever is behind the element rather than the element itself, which is how frosted-glass headers and modal backdrops are made. It only shows if the element is partly transparent — with a solid background there is nothing to see through — and it is one of the more expensive things you can ask a browser to do, so keep it to one or two elements.

Example
/* Text over a photo */
.hero h1 { text-shadow: 0 2px 8px rgba(0, 0, 0, 0.6); }

/* Filters, chained */
.logo        { filter: grayscale(1) opacity(0.6); transition: filter 0.25s ease; }
.logo:hover  { filter: grayscale(0) opacity(1); }

.thumbnail   { filter: brightness(0.9) saturate(1.1); }

/* drop-shadow follows the artwork, not the box */
.icon { filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); }

/* Frosted glass — needs a translucent background to be visible */
.sticky-bar {
  background: rgba(255, 255, 255, 0.7);
  backdrop-filter: blur(12px);
}
  • text-shadow has no spread value — offsets, blur and colour only
  • filter affects the element and all its children
  • filter: drop-shadow() follows transparency; box-shadow follows the box
  • backdrop-filter needs a partly transparent background to have any effect
  • Any filter other than none creates a stacking context and breaks position: fixed for descendants — the same trap as transform

Keeping Effects Cheap and Tasteful

Blur is expensive. A large blur radius means the browser is averaging a lot of pixels, and doing that on every frame of an animation is one of the reliable ways to make a page stutter on a phone. Animating box-shadow directly forces a repaint each frame for exactly that reason.

There is a well-known way around it. Put the hover shadow on a pseudo-element that sits behind the card, give it opacity: 0 in the resting state, and transition the opacity instead. The browser then fades a layer it has already drawn rather than recalculating a blur sixty times a second, and the effect looks identical. For most projects the simple version is fine — but if a card grid feels sluggish on a mid-range phone, this is the first thing to try.

The design advice is shorter than the technical advice: shadows work by contrast, so if everything is elevated then nothing is. Pick your three levels, use the lowest one by default, and reserve the strongest for things that genuinely float above the page. Combining a heavy shadow with a strong border and a bright background usually means all three are fighting, and removing two of them makes the component look better immediately.

Finally, treat effects as enhancement. A design that only communicates through shadow is fragile — in high-contrast mode, on a cheap screen in bright sunlight, or on a printed page, that shadow may not be visible at all. Keep the structure legible without it.

Example
/* Straightforward, and fine for most pages */
.card {
  box-shadow: var(--shadow-1);
  transition: box-shadow 0.25s ease, transform 0.25s ease;
}
.card:hover {
  box-shadow: var(--shadow-2);
  transform: translateY(-4px);
}

/* Cheaper: fade a pre-drawn shadow layer instead of redrawing the blur */
.card { position: relative; box-shadow: var(--shadow-1); }

.card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: var(--shadow-3);
  opacity: 0;
  transition: opacity 0.25s ease;
  pointer-events: none;   /* never intercept clicks */
  z-index: -1;
}

.card:hover::after { opacity: 1; }
Notes
  • border-radius: inherit on a pseudo-element overlay is worth remembering. It keeps the overlay's corners matched to the parent's automatically, so changing the card's radius later does not leave square corners poking out from behind it.
Ask AI