CSS Module 8 – Backgrounds, borders, images and effects
Backgrounds, borders and shadows
Layer CSS backgrounds with size, position, origin and clip, round corners, draw gradient borders and text, and build depth with layered box-shadow.
What you will learn
- Build layered backgrounds with size, position, repeat, origin and clip
- Use border-radius, border-image and outlines deliberately
- Create depth with layered box-shadow and text-shadow
- Avoid the resets of the background and border shorthands
- Check gradient text, borders and shadows in forced colours mode
Before you start
What you’ll learn · Before you start
What you will learn
- Build layered backgrounds with size, position, repeat, origin and clip
- Use border-radius, border-image and outlines deliberately
- Create depth with layered box-shadow and text-shadow
- Avoid the resets of the background and border shorthands
- Check gradient text, borders and shadows in forced colours mode
Before you start
On this page
Every element can paint three kinds of decoration around its content. Backgrounds are layers painted behind the
content: a colour at the bottom, then any number of images or gradients, listed with commas, the first one on top.
Borders frame the padding box and are the only one of the three that takes space. Shadows add depth:
box-shadow draws blurred copies of the box outside it or inside it, and text-shadow does the same for text. Three
rules explain most surprises: the background and border shorthands reset every part you leave out,
border-radius clips backgrounds and clicks but not border images, and shadows and outlines never change the layout.
For every rule there are numbers from Chromium 153, Firefox 155 and WebKit 26.6, and a look at what forced colours
mode does to each decoration.
Background layers, from the top down
background-image takes a comma-separated list, and each item is a layer. The first item is the layer closest
to the reader, the next is painted behind it, and background-color is painted below all of them. The other
background properties take lists too, one value per layer, in the same order. A list that is too short is repeated
until every layer has a value, and extra values are dropped, so background-repeat: no-repeat with three images
means no-repeat for all three.
The order in which one element paints its decoration
Text description of the diagram
The diagram is a column of seven steps, from the back of the element to the front.
- Outer box-shadows. When there are several, the first one listed is painted on top of the others.
- The background colour, clipped to the same box as the bottom image layer.
- The background image layers. The first one listed is on top, closest to the reader.
- Inset box-shadows, drawn inside the padding box, above the background.
- The border, or the border image when there is one.
- The content: the element's text and its child boxes.
- The outline, which is drawn over everything else and takes no space.
A banner with a photo and a dark overlay for its heading is two layers. The gradient comes first, so it is drawn over the photo:
/* A hero banner with a photo and a gradient overlay, as two background layers. The first layer is drawn on top. */
.hero {
min-block-size: 60vh;
color: #fff;
background:
/* Layer 1, on top: dark at the bottom, where the white heading sits, clear from 60 % up. */
linear-gradient(to top, rgb(0 0 0 / 0.7), rgb(0 0 0 / 0) 60%),
/* Layer 2: the photo covers the box; 70 % 35 % keeps the stall-holder in view when the sides are cropped.
The colour, allowed only in the last layer, shows until the photo arrives. */
url("market-stall.jpg") 70% 35% / cover no-repeat #3a2f28;
}
/* Changing one thing later: use the longhand. `background: #102030` here would also reset both images to none. */
.hero--evening {
background-color: #102030;
} Each layer has its own size, position and repeat:
background-size: coverscales the image, keeping its shape, until it covers the whole area, and crops what sticks out.containscales it until it just fits, which leaves empty bands beside it when it does not repeat.background-positionplaces the image. A percentage lines up the same point of the image and of the area:70% 35%puts the point 70 % across and 35 % down the photo on the point 70 % across and 35 % down the box. That is why a percentage works as a focal point: whencovercrops the sides, the part of the photo near that point stays in view. The images lesson works the same arithmetic through forobject-position.background-repeattiles the image (repeat, the default), or shows it once (no-repeat);spaceandroundfit a whole number of tiles.background-attachment: fixedpins a layer to the viewport. It has limited availability: Safari on iOS accepts the value and ignores it, so the background scrolls with the page there.
The colour can appear only in the last layer of the shorthand, which is why the example writes it after the photo.
To paint a plain colour in an upper layer, use a gradient from a colour to itself, such as
linear-gradient(#fff, #fff).
The shorthand resets what you leave out
background is a shorthand, and a shorthand sets every one of its longhands. For each layer it first puts
background-image, background-position, background-size, background-repeat, background-origin,
background-clip and background-attachment back to their initial values, then applies what you wrote, and it sets
background-color to your colour or to transparent. The border shorthand also resets border-image. The lab
tests both and two neighbouring rules:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Backgrounds, borders and shadows lab</title>
<style>
body { margin: 0; font: 16px/1.4 system-ui, sans-serif; background: #fff; }
/* 1. The background shorthand resets every longhand it does not mention. */
.hero { background-image: linear-gradient(#0b3d5c, #1b6d8c); background-size: cover; }
.hero.dark { background: #102030; } /* meant to change the colour only */
.hero.dark-fixed { background-color: #102030; } /* the longhand changes one thing */
/* 2. The border shorthand resets border-image. */
.frame-a { border-image: linear-gradient(90deg, #c2185b, #ff9800) 1; border: 4px solid; }
.frame-b { border: 4px solid; border-image: linear-gradient(90deg, #c2185b, #ff9800) 1; }
/* 3. A border needs a style: without one its width computes to 0. */
.no-style { border-width: 4px; }
.with-style { border-width: 4px; border-style: solid; }
/* 4. One value for three layers: the list repeats for each layer. */
.layers { background-image: linear-gradient(red, red), linear-gradient(blue, blue), linear-gradient(lime, lime); background-repeat: no-repeat; }
/* 5. Rounded corners clip hit testing too. */
.wrap { position: absolute; left: 20px; top: 20px; padding: 20px; background: #eee; }
.pill { display: block; width: 200px; height: 40px; border: 0; border-radius: 9999px; background: #1a5fb4; }
/* 6. Shadows, outlines and borders in a 300px scroll container. */
.scroller { position: absolute; left: 20px; top: 120px; width: 300px; overflow: auto; }
.scroller > div { width: 300px; height: 40px; background: #ccc; }
.shadowed > div { box-shadow: 0 0 0 40px #888; }
.outlined > div { outline: 40px solid #888; }
.bordered > div { border: 40px solid #888; }
/* 7. A three-layer shadow and a shadow without a colour, as getComputedStyle reports them. */
.card { box-shadow: 0 1px 2px rgb(0 0 0 / 0.2), 0 2px 6px rgb(0 0 0 / 0.15), 0 8px 24px rgb(0 0 0 / 0.1); }
.pressed { color: #1a5fb4; box-shadow: inset 0 2px 4px; }
/* 8. The bottom radii need 240px of a 120px side, so every radius is halved: the top corners get 12px, not 24px. */
.tag { position: absolute; left: 40px; top: 400px; width: 120px; height: 160px; background: #1a5fb4; border-radius: 24px 24px 120px 120px; }
/* 9. Forced colours mode: a card with a shadow and a transparent border, and a rounded gradient border. */
.framed { box-shadow: 0 2px 8px rgb(0 0 0 / 0.3); border: 1px solid transparent; }
.badge { border: 3px solid transparent; border-radius: 12px;
background: linear-gradient(#fff, #fff) padding-box, linear-gradient(135deg, #b0124f, #b34700) border-box; }
/* 10. A 20px blur on a white page: how dark is the shadow at each distance from the edge? */
.blur { position: absolute; left: 400px; top: 300px; width: 200px; height: 200px; background: #000; box-shadow: 0 0 20px #000; }
</style>
</head>
<body>
<div class="hero dark" id="dark"></div>
<div class="hero dark-fixed" id="dark-fixed"></div>
<div class="frame-a" id="frame-a"></div>
<div class="frame-b" id="frame-b"></div>
<div class="no-style" id="no-style"></div>
<div class="with-style" id="with-style"></div>
<div class="layers" id="layers"></div>
<div class="card" id="card"></div>
<div class="pressed" id="pressed"></div>
<div class="wrap" id="wrap"><button class="pill" id="pill" type="button"></button></div>
<div class="scroller shadowed" id="shadowed"><div></div></div>
<div class="scroller outlined" id="outlined"><div></div></div>
<div class="scroller bordered" id="bordered"><div></div></div>
<div class="framed" id="framed"></div>
<div class="badge" id="badge"></div>
<div class="tag"></div>
<div class="blur"></div>
<script>
const css = (id, prop) => getComputedStyle(document.getElementById(id)).getPropertyValue(prop);
console.log(`background shorthand: background-image ${css('dark', 'background-image')}`);
console.log(`background-color longhand: background-image ${css('dark-fixed', 'background-image').slice(0, 30)}…`);
console.log(`border after border-image: border-image-source ${css('frame-a', 'border-image-source')}`);
console.log(`border-image after border: border-image-source ${css('frame-b', 'border-image-source').slice(0, 30)}…`);
console.log(`border-width without a style: ${css('no-style', 'border-top-width')}; with solid: ${css('with-style', 'border-top-width')}`);
console.log(`background-repeat for three layers: ${css('layers', 'background-repeat')}`);
console.log(`three-layer shadow: ${css('card', 'box-shadow')}`);
console.log(`inset shadow without a colour: ${css('pressed', 'box-shadow')}`);
// Hit testing: 3px inside the pill's top-left corner, and its centre.
const r = document.getElementById('pill').getBoundingClientRect();
const corner = document.elementFromPoint(r.left + 3, r.top + 3);
const centre = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
console.log(`pill corner hits: ${corner.id}; pill centre hits: ${centre.id}`);
for (const id of ['shadowed', 'outlined', 'bordered']) {
const s = document.getElementById(id);
console.log(`${id}: scrollWidth ${s.scrollWidth}px in a ${s.clientWidth}px container, child ${s.firstElementChild.offsetWidth}px`);
}
console.log(`forced colours ${matchMedia('(forced-colors: active)').matches ? 'on' : 'off'}: card box-shadow ${css('framed', 'box-shadow')}, border ${css('framed', 'border-top-color')}`);
console.log(`badge: background-image ${css('badge', 'background-image').slice(0, 24)}…, border ${css('badge', 'border-top-color')}`);
console.log(`CSS.supports background-clip: text ${CSS.supports('background-clip', 'text')}; -webkit-background-clip: text ${CSS.supports('-webkit-background-clip', 'text')}`);
// The points whose colour the measuring script reads from a screenshot of this page. The tag's pixel 5 px in from
// its top-left corner is blue with a 12px corner and white with a 24px one. The black box ends at x = 600, so the
// other points are 0 to 20 px outside the edge of its shadow, half way down.
window.samples = [
{ name: 'tag, 5px in from the top-left corner', x: 45, y: 405, size: 1 },
...[0, 5, 10, 15, 20].map((d) => ({ name: `pixel ${d} beyond the edge`, x: 600 + d, y: 400, size: 1 })),
];
</script>
</body>
</html> | Case | Result in all three engines |
|---|---|
background-image set, then background: #102030 |
background-image: none: the photo is gone |
background-image set, then background-color: #102030 |
the image stays |
border-image set, then border: 4px solid |
border-image-source: none |
border: 4px solid, then border-image |
the border image stays |
border-width: 4px with no border-style |
border-top-width: 0px |
The first row is a classic bug in themes: a dark-mode rule that writes background: #102030 to change a colour
also removes the hero photo, because the image was set in another rule. Use a shorthand when you mean to set the whole
background, and a longhand such as background-color to change one part. The last row is a rule of borders: a border
needs a style, and while border-style is none, its width computes to zero, so border-width on its own draws
nothing.
One serialising detail differs: for background-repeat: no-repeat with three images, Chromium reports
no-repeat, no-repeat, no-repeat and Firefox and WebKit report no-repeat. The value applies to all three layers in
each engine; only the text DevTools and getComputedStyle() show is different.
Where a background starts and stops: origin and clip
Two properties take a box keyword, border-box, padding-box or content-box, and they are easy to mix up.
background-origin sets the positioning area: where 0% 0% is and what cover and percentages of
background-size measure against. Its initial value is padding-box. background-clip sets the painting area:
where the layer stops being drawn. Its initial value is border-box, so a background normally shows through a
dashed or semi-transparent border. In the shorthand, one box keyword sets both, and two set the origin, then the
clip.
Gradient text with background-clip: text
background-clip: text paints the background only inside the glyphs of the text. The text’s own colour is still
drawn on top, so the usual recipe makes it transparent. The web-features data lists background-clip: text as
limited availability, although all three engines accept it, because one engine’s support is marked partial: the
compatibility data says that in Firefox it does not work with background-attachment: fixed or in multi-layer
backgrounds that mix text with other values. The lab confirmed the second, and found two more ways gradient text
goes wrong:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gradient text lab</title>
<style>
body { margin: 0; background: #fff; }
/* Each line is three full-block characters, so the middle of the line is inside a glyph. */
div { font: 700 60px system-ui, sans-serif; line-height: 80px; inline-size: 400px;
background-image: linear-gradient(90deg, #b0124f, #b34700); background-clip: text; }
.transparent { color: transparent; }
.shadowed { color: transparent; text-shadow: 0 1px 2px #000; }
.fill { color: #b0124f; -webkit-text-fill-color: transparent; }
.forced-rule { color: transparent; }
@media (forced-colors: active) { .forced-rule { color: CanvasText; } }
/* Two layers: the text layer on top of a grey box. */
.two-layers { color: transparent; background-image: linear-gradient(#b0124f, #b0124f), linear-gradient(#ddd, #ddd);
background-clip: text, border-box; }
</style>
</head>
<body>
<div class="transparent">███</div>
<div class="shadowed">███</div>
<div class="fill">███</div>
<div class="forced-rule">███</div>
<div class="two-layers">███</div>
<script>
console.log(`forced colours: ${matchMedia('(forced-colors: active)').matches ? 'on' : 'off'}`);
// The points whose colour the measuring script reads from a screenshot of this page: the middle of each line of
// text, and for the two-layer case also a point of the box to the right of the text.
window.samples = [];
for (const el of document.querySelectorAll('div')) {
const range = document.createRange();
range.selectNodeContents(el);
const glyphs = range.getClientRects()[0];
window.samples.push({ name: el.className, x: glyphs.left + glyphs.width / 2, y: glyphs.top + glyphs.height / 2 });
}
const last = document.querySelector('.two-layers').getBoundingClientRect();
window.samples.push({ name: 'two-layers, the box beside the text', x: last.right - 20, y: last.top + last.height / 2 });
</script>
</body>
</html> | Variant | Normal mode | Forced colours, Chromium | Forced colours, Firefox |
|---|---|---|---|
color: transparent |
the gradient | black text | invisible |
the same with a text-shadow |
black: the shadow covers the gradient | black text | invisible |
color: #b0124f and -webkit-text-fill-color: transparent |
the gradient | black text | black text |
color: transparent and a forced-colors rule |
the gradient | black text | black text |
two layers, clipped text, border-box |
pink text on a grey box; in Firefox, pink over the whole box | black text | invisible |
Normal mode was the same in all three engines except for the last row. Forced colours mode was emulated in Chromium and Firefox; WebKit’s emulation only matches the media query and forces no colours. Three rules follow:
- No text-shadow on gradient text. A text shadow is painted above the element’s background and below its text, and it is not clipped to the glyphs, so with transparent text it covers the gradient.
- One background layer. Mixing
textwith other clip values is where Firefox draws the layer over the whole box. - Give forced colours mode a real colour. That mode computes a
background-imagewithout aurl()tonone, so the gradient disappears; Chromium then forces the transparent text to the text colour, but Firefox kept it transparent and the text vanished. A rule inside@media (forced-colors: active)that setscolor: CanvasTextfixed it in both.
/* Gradient text that stays readable everywhere: a solid colour first, the gradient only where background-clip: text
is supported, one background layer only, no text-shadow, and the plain text colour back in forced colours mode. */
.headline {
color: #b0124f;
}
@supports (background-clip: text) {
.headline {
background-image: linear-gradient(90deg, #b0124f, #b34700);
background-clip: text;
color: transparent;
}
}
@media (forced-colors: active) {
.headline {
color: CanvasText;
}
} Check the contrast of every colour in the gradient against the page, not only the first one: WCAG 2.2 asks for 4.5:1
for normal text and 3:1 for large text. The recipe’s two ends measure 6.90:1 and 5.50:1 on white, while a pink to
orange gradient such as #c2185b to #ff9800 ends at 2.16:1, too faint even for large text.
Rounded corners with border-radius
border-radius rounds the outer edge of the border box. Each corner has a horizontal and a vertical radius: one value
gives a circular corner, and border-radius: 40px / 20px gives elliptical ones, with the horizontal radii before the
slash. Percentages are relative to the box, the horizontal radius to its width and the vertical radius to its height,
so 50% on a rectangle makes an ellipse, not a pill. The padding edge is rounded by the outer radius minus the border
width, never below zero.
When two radii on the same side add up to more than that side, browsers scale every radius of the box down by
the same factor until nothing overlaps. That is why 9999px makes a pill: on a 40 px tall button, every radius
shrinks to 20 px. It also has a side effect people rarely expect: corners that were small enough get smaller too.
// The radii a browser really draws (CSS Backgrounds 3, section 4.5). When the two radii that meet on one side add up
// to more than that side, every radius of the box is multiplied by the same factor f, the smallest ratio of a side
// to the sum of its two radii. Each corner has a horizontal and a vertical radius: [h, v].
function usedRadii(width, height, corners) {
const [tl, tr, br, bl] = corners;
const f = Math.min(
1,
width / (tl[0] + tr[0]), // top side
width / (bl[0] + br[0]), // bottom side
height / (tl[1] + bl[1]), // left side
height / (tr[1] + br[1]), // right side
);
return { f, corners: corners.map(([h, v]) => [h * f, v * f]) };
}
const px = (n) => `${Math.round(n * 100) / 100}`;
const same = (r) => [r, r];
const cases = [
['200 x 40 button, border-radius: 9999px', 200, 40, [same(9999), same(9999), same(9999), same(9999)]],
['200 x 40 button, border-radius: 50%', 200, 40, [[100, 20], [100, 20], [100, 20], [100, 20]]],
['300 x 200 card, border-radius: 16px', 300, 200, [same(16), same(16), same(16), same(16)]],
['120 x 160 tag, border-radius: 24px 24px 120px 120px', 120, 160, [same(24), same(24), same(120), same(120)]],
];
for (const [label, w, h, corners] of cases) {
const { f, corners: used } = usedRadii(w, h, corners);
const shown = used.map(([a, b]) => (a === b ? `${px(a)}` : `${px(a)}/${px(b)}`)).join(' ');
console.log(`${label}\n f = ${f === 1 ? '1, nothing to reduce' : f.toPrecision(3)}; corners (tl tr br bl): ${shown} px`);
} Output
200 x 40 button, border-radius: 9999px f = 0.00200; corners (tl tr br bl): 20 20 20 20 px 200 x 40 button, border-radius: 50% f = 1, nothing to reduce; corners (tl tr br bl): 100/20 100/20 100/20 100/20 px 300 x 200 card, border-radius: 16px f = 1, nothing to reduce; corners (tl tr br bl): 16 16 16 16 px 120 x 160 tag, border-radius: 24px 24px 120px 120px f = 0.500; corners (tl tr br bl): 12 12 60 60 px
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node radii.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The lab checked the tag of the last case in all three engines by reading one pixel 5 px in from its top-left corner:
with a 24 px corner that pixel would be outside the curve, and in all three it was painted, because the corner was
12 px. Rounded corners clip more than the background, too: they clip the content of images and of boxes whose
overflow is not visible, and they clip hit testing. A click 3 px inside the corner of the 9999px pill reached
the element behind it in all three engines. Keep that in mind for small round buttons: the corners are no longer part
of the target.
Borders and border images
The border shorthand sets the width, style and colour of all four sides, and border-top, border-inline-start
and the others set one side. border-image replaces the border’s style with an image or a gradient, cut into
nine pieces: four corners, four edges and a middle that is dropped unless you add fill.
border-image: linear-gradient(90deg, #b0124f, #b34700) 1 is the shortest gradient border: the 1 is the slice, one
pixel in from each edge of the gradient, and those strips are stretched over the border. A border image changes
nothing in the layout, which still comes from border-width and border-style, and it needs a border style, or the
width is zero.
border-radius does not round a border image: the corners stay square. For a rounded gradient border, draw the
border with two background layers instead, the padding box in the card’s colour on top and the gradient out to the
border edge beneath it, behind a transparent border:
/* A rounded gradient border. border-image ignores border-radius, so the border is drawn with two background layers
instead: the top one fills the padding box with the card's colour, the bottom one paints the gradient out to the
border edge, under a transparent border. */
.badge {
border: 3px solid transparent;
border-radius: 12px;
background:
linear-gradient(#fff, #fff) padding-box, /* a colour is allowed only in the last layer, so: a one-colour gradient */
linear-gradient(135deg, #b0124f, #b34700) border-box;
}
/* Forced colours mode removes both gradients and turns the transparent border into a real one in a system colour,
so the badge keeps a visible frame without any extra rule. */ This approach needs the card to have a solid colour, because the top layer has to hide the gradient everywhere but the border. In forced colours mode it also degrades well: in the lab, Chromium and Firefox removed both gradients and forced the transparent border to a system colour, black on the default palette, so the badge kept a 3 px frame.
Outlines
An outline is drawn just outside the border edge, over everything else, and takes no space, so showing or hiding
it never moves anything. Current engines draw it around the border-radius curve. outline-offset moves it out, and
a negative offset moves it inside the border box. Two uses follow from that. Focus rings, which the box model lesson
covers in depth with forced colours mode, are the main one. The other is debugging: * { outline: 1px solid red; }
shows every box on a page without changing a single size, which a border would.
Shadows: box-shadow and text-shadow
A box-shadow is up to four lengths, a colour and the keyword inset: the horizontal and vertical offsets, the
blur radius and the spread distance. The two offsets are required; a missing blur or spread is 0, and a missing colour
is currentColor. Several shadows
are separated by commas, and the first is on top. An outer shadow is a copy of the border box, moved, grown by
the spread and blurred, and it is clipped so that it only appears outside the box: a translucent card does not show
its own shadow through itself. An inset shadow is drawn inside the padding box, above the background, which is
why it suits a pressed button.
Shadows never change the layout. The lab put a 300 px wide child in a 300 px scroll container and gave it a 40 px spread shadow, then a 40 px outline, then a 40 px border:
| Child decoration | Container’s scrollWidth |
Child’s width |
|---|---|---|
box-shadow: 0 0 0 40px |
300 px | 300 px |
outline: 40px solid |
300 px | 300 px |
border: 40px solid |
380 px | 380 px |
All three engines agreed: the shadow and the outline added no scrollable overflow, and only the border made the box
wider. text-shadow takes the same values without the spread and inset. It is useful for light text over a photo,
where a soft dark shadow such as 0 1px 3px rgb(0 0 0 / 0.6) separates the letters from a busy background.
How far a blur reaches
The specification ties the blur radius to a Gaussian blur whose standard deviation is half the blur radius, so the
edge of a shadow fades over about twice the blur radius, centred on the shadow’s edge. The program predicts the
opacity of a 0 0 20px black shadow at each distance beyond the box:
// How dark a shadow is near its edge (CSS Backgrounds 3, section 6.1.2): browsers blur it like a Gaussian whose
// standard deviation is half the blur radius. Next to a long straight edge, the shadow's opacity at distance d
// outside the edge is then 0.5 × erfc(d / (σ × √2)). Change `blur` and run it again.
const blur = 20; // px, the third length of box-shadow
const sigma = blur / 2;
// erf() by Abramowitz and Stegun, formula 7.1.26 (error below 1.5e-7): JavaScript's Math has no erf.
function erf(x) {
const s = Math.sign(x);
const t = 1 / (1 + 0.3275911 * Math.abs(x));
const poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
return s * (1 - poly * Math.exp(-x * x));
}
const opacity = (d) => 0.5 * (1 - erf(d / (sigma * Math.SQRT2)));
console.log(`box-shadow: 0 0 ${blur}px black on a white page (σ = ${sigma}px)`);
console.log('pixel centre shadow value on white');
for (const pixel of [0, 5, 10, 15, 20]) {
const d = pixel + 0.5; // a pixel's centre lies half a pixel further out than its left edge
const a = opacity(d);
const value = Math.round(255 * (1 - a));
console.log(`${String(pixel).padStart(5)} ${d.toFixed(1).padStart(5)}px ${(a * 100).toFixed(1).padStart(5)} % ${value}`);
}
const inner = opacity(-blur), outer = opacity(blur);
console.log(`From ${blur}px inside the edge to ${blur}px outside, the shadow fades from ${(inner * 100).toFixed(1)} % to ${(outer * 100).toFixed(1)} %.`); Output
box-shadow: 0 0 20px black on a white page (σ = 10px)
pixel centre shadow value on white
0 0.5px 48.0 % 133
5 5.5px 29.1 % 181
10 10.5px 14.7 % 218
15 15.5px 6.1 % 240
20 20.5px 2.0 % 250
From 20px inside the edge to 20px outside, the shadow fades from 97.7 % to 2.3 %.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node blur_profile.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The lab measured the same shadow on a white page, reading the pixels 0 to 20 px to the right of a black box:
| Pixel beyond the edge | 0 | 5 | 10 | 15 | 20 |
|---|---|---|---|---|---|
| Predicted value | 133 | 181 | 218 | 240 | 250 |
| Chromium 153 | 132 | 178 | 215 | 238 | 250 |
| Firefox 155 | 133 | 181 | 219 | 241 | 252 |
| WebKit 26.6 | 136 | 189 | 228 | 248 | 255 |
Chromium and Firefox stay within four levels of the Gaussian. WebKit’s blur is narrower and reaches white at 20 px; the specification leaves the exact algorithm to each browser and asks only for a close approximation. The practical rule holds everywhere: a shadow with a 20 px blur needs about 20 px of room on each side before it fades out, so keep that much space around it, or a container that clips its overflow will cut it off.
Elevation from layered shadows
A real shadow has a dark, sharp part close to the object and a wide, faint part further away, and one box-shadow
cannot draw both. Design systems stack two or three: a small dark layer, a medium one and a wide, faint one, and
they lift the whole set at each step of an elevation scale. Keep the scale in custom properties, so every
component uses the same few values:
/* A card at two elevations. Each level stacks three shadows, front to back: a small, dark one close to the card, a
softer one, and a wide, faint one. The tokens live in custom properties, so every component shares them. */
:root {
--elevation-rest: 0 1px 2px rgb(0 0 0 / 0.2), 0 2px 6px rgb(0 0 0 / 0.15), 0 8px 24px rgb(0 0 0 / 0.1);
--elevation-raised: 0 2px 4px rgb(0 0 0 / 0.2), 0 6px 12px rgb(0 0 0 / 0.15), 0 16px 40px rgb(0 0 0 / 0.12);
}
.card {
border: 1px solid transparent; /* invisible now; a real frame in forced colours mode, where shadows are removed */
border-radius: 12px;
background: #fff;
box-shadow: var(--elevation-rest);
}
.card:hover {
box-shadow: var(--elevation-raised);
}
/* A pressed button looks pushed in: an inset shadow is drawn inside the padding box. */
.button:active {
box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.25);
} All three engines report a computed box-shadow in the same form, colour first, then all four lengths, then
inset: the resting card’s value comes back as rgba(0, 0, 0, 0.2) 0px 1px 2px 0px, rgba(0, 0, 0, 0.15) 0px 2px 6px 0px, rgba(0, 0, 0, 0.1) 0px 8px 24px 0px, and a shadow written without a colour comes back in the element’s text
colour.
Shadows in forced colours mode
Forced colours mode, such as a Windows contrast theme, computes every box-shadow and text-shadow to none.
Chromium and Firefox did exactly that in the lab, while a border: 1px solid transparent on the same card was forced
to a visible system colour. So a shadow can show elevation, but it should never be the only edge of a card or a
control: give it a transparent border, as the elevation example does, and the edge comes back where shadows go.
Practise: an elevation scale
The exercise builds the tokens of a four-level scale and reads a computed box-shadow back into its layers, the way
a test of a design system would.
Exercise · Easy · JavaScript
Build an elevation scale and read shadows back
A design system has four elevation levels, and each level is a box-shadow of three layers, listed front to back. For level n (1 to 4), let u be 2 to the power n − 1, so 1, 2, 4 or 8 px. The three layers are 0 u 2u rgb(0 0 0 / 0.2), 0 2u 4u rgb(0 0 0 / 0.14) and 0 4u 8u rgb(0 0 0 / 0.08): each layer doubles the offset and the blur of the one in front of it, and each level doubles everything again. Write three functions in shadows.mjs and export them.
The first, elevation(level), returns the value for one level as a string, with the layers separated by a comma and a space, for example 0 1px 2px rgb(0 0 0 / 0.2), 0 2px 4px rgb(0 0 0 / 0.14), 0 4px 8px rgb(0 0 0 / 0.08) for level 1. Any level other than 1, 2, 3 or 4 throws a RangeError.
The second, tokens(), returns the custom properties of the scale as an object: the keys --elevation-1 to --elevation-4, each with the value elevation gives.
The third, parseShadows(value), reads a value the way getComputedStyle() reports it and returns its layers as objects { color, x, y, blur, spread, inset }, front to back. Browsers write the colour first, as rgb(…) or rgba(…), then four lengths in px, then inset if the shadow is inset, so rgba(0, 0, 0, 0.2) 0px 1px 2px 0px becomes { color: 'rgba(0, 0, 0, 0.2)', x: 0, y: 1, blur: 2, spread: 0, inset: false }. The lengths are numbers, and none gives an empty list. Watch out: the colours contain commas too.
The sample tests import the three functions from shadows.mjs and run in your browser.
Starter code · shadows.mjs
/** The box-shadow value of one elevation level (1 to 4). */
export function elevation(level) {
// Replace this line with your code.
return 'none';
}
/** The custom properties of the scale: --elevation-1 to --elevation-4. */
export function tokens() {
// Replace this line with your code.
return {};
}
/** The layers of a computed box-shadow value, front to back. */
export function parseShadows(value) {
// Replace this line with your code.
return [];
} The sample tests · shadows.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { elevation, tokens, parseShadows } from './shadows.mjs';
test('level 1 is three layers that double their offset and blur', () => {
assert.equal(elevation(1), '0 1px 2px rgb(0 0 0 / 0.2), 0 2px 4px rgb(0 0 0 / 0.14), 0 4px 8px rgb(0 0 0 / 0.08)');
});
test('each level doubles everything again', () => {
assert.equal(elevation(3), '0 4px 8px rgb(0 0 0 / 0.2), 0 8px 16px rgb(0 0 0 / 0.14), 0 16px 32px rgb(0 0 0 / 0.08)');
assert.equal(elevation(4), '0 8px 16px rgb(0 0 0 / 0.2), 0 16px 32px rgb(0 0 0 / 0.14), 0 32px 64px rgb(0 0 0 / 0.08)');
});
test('levels outside 1 to 4 are refused', () => {
assert.throws(() => elevation(0), RangeError);
assert.throws(() => elevation(5), RangeError);
assert.throws(() => elevation(1.5), RangeError);
});
test('the tokens are the four levels as custom properties', () => {
assert.deepEqual(tokens(), {
'--elevation-1': elevation(1),
'--elevation-2': elevation(2),
'--elevation-3': elevation(3),
'--elevation-4': elevation(4),
});
});
test('none has no layers', () => {
assert.deepEqual(parseShadows('none'), []);
});
test('a three-layer shadow as Chromium, Firefox and WebKit report it', () => {
const computed = 'rgba(0, 0, 0, 0.2) 0px 1px 2px 0px, rgba(0, 0, 0, 0.15) 0px 2px 6px 0px, rgba(0, 0, 0, 0.1) 0px 8px 24px 0px';
assert.deepEqual(parseShadows(computed), [
{ color: 'rgba(0, 0, 0, 0.2)', x: 0, y: 1, blur: 2, spread: 0, inset: false },
{ color: 'rgba(0, 0, 0, 0.15)', x: 0, y: 2, blur: 6, spread: 0, inset: false },
{ color: 'rgba(0, 0, 0, 0.1)', x: 0, y: 8, blur: 24, spread: 0, inset: false },
]);
});
test('an inset shadow, a negative offset and a spread', () => {
assert.deepEqual(parseShadows('rgb(26, 95, 180) 0px 2px 4px 0px inset'), [
{ color: 'rgb(26, 95, 180)', x: 0, y: 2, blur: 4, spread: 0, inset: true },
]);
assert.deepEqual(parseShadows('rgba(0, 0, 0, 0.5) -3px 4px 0px 2px'), [
{ color: 'rgba(0, 0, 0, 0.5)', x: -3, y: 4, blur: 0, spread: 2, inset: false },
]);
});
test('level 2 read back from the computed value', () => {
const layers = parseShadows('rgba(0, 0, 0, 0.2) 0px 2px 4px 0px, rgba(0, 0, 0, 0.14) 0px 4px 8px 0px, rgba(0, 0, 0, 0.08) 0px 8px 16px 0px');
assert.deepEqual(layers.map((l) => l.y), [2, 4, 8]);
assert.deepEqual(layers.map((l) => l.blur), [4, 8, 16]);
}); A hint
2 ** (level - 1) gives u. To split a computed value into layers, walk through it character by character and count parentheses: a comma separates two layers only when you are not inside rgb(…) or rgba(…). Then each layer is the colour up to its closing parenthesis, followed by the lengths, which parseFloat turns into numbers.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Interview questions
Fresher · In what order does an element paint its backgrounds, shadows, border and outline?
From the back: outer box-shadows, then background-color, then the background image layers, then inset
box-shadows, then the border or border image, then the content, and the outline over everything. Within a list, the
first item is on top: the first shadow listed is drawn over the others, and the first background image is the
layer closest to the reader, so an overlay gradient is written before the photo it darkens. The background colour
can only appear in the last layer of the background shorthand and is clipped to the same box as the bottom layer.
None of these paints changes the layout, except the border, which takes space.
Key takeaways
- Background layers are listed with commas and the first is on top;
background-coloris always at the bottom and can only be written in the last layer of the shorthand. backgroundresets every background longhand you leave out, andborderresetsborder-image; change one part with its longhand.background-origindecides where a layer is positioned,background-clipwhere it is painted. For gradient text, use one layer, no text shadow and a forced-colours rule that restores the text colour.border-radiusscales every radius down when two overlap, clips backgrounds, images and clicks, and does not round border images; draw rounded gradient borders with two background layers.- Outlines, box shadows and text shadows take no space and add no scrollable overflow. A blur fades over about twice its radius.
- Stack two or three shadows for elevation, keep them in custom properties, and give cards a transparent border, because forced colours mode removes shadows.
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- CSS Backgrounds and Borders Module Level 3: layering multiple background images (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: the background shorthand (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: background-position (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: overlapping curves (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: corner clipping (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: border-width (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: border shorthands (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: border images (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: box-shadow (W3C CSS Working Group)
- CSS Backgrounds and Borders Module Level 3: blurring shadow edges (W3C CSS Working Group)
- CSS Backgrounds Module Level 4: background-clip (W3C CSS Working Group)
- CSS Basic User Interface Module Level 4: outline properties (W3C CSS Working Group)
- CSS Text Decoration Module Level 3: text shadows (W3C CSS Working Group)
- CSS Color Adjustment Module Level 1: properties affected by forced colors mode (W3C CSS Working Group)
- Web Content Accessibility Guidelines (WCAG) 2.2: Contrast (Minimum) (W3C)
- background-clip (MDN Web Docs) (Mozilla)
- background-attachment (MDN Web Docs) (Mozilla)
- box-shadow (MDN Web Docs) (Mozilla)
- Web features explorer: background-clip: text (W3C WebDX Community Group)
- Web features explorer: background-attachment (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress