Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 25 minutes

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.

One element paints its outer shadows, background colour, background images, inset shadows, border, content and outline, back to front.1. Outer box-shadowsthe first one listed is on top2. background-colorclipped like the bottom image layer3. background-image layersthe first one listed is on top4. Inset box-shadows5. The border, or the border image6. The content: text and child boxes7. The outline, over everything else

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.

  1. Outer box-shadows. When there are several, the first one listed is painted on top of the others.
  2. The background colour, clipped to the same box as the bottom image layer.
  3. The background image layers. The first one listed is on top, closest to the reader.
  4. Inset box-shadows, drawn inside the padding box, above the background.
  5. The border, or the border image when there is one.
  6. The content: the element's text and its child boxes.
  7. 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 photo and a gradient overlay CSS · hero.css
/* 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: cover scales the image, keeping its shape, until it covers the whole area, and crops what sticks out. contain scales it until it just fits, which leaves empty bands beside it when it does not repeat.
  • background-position places 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: when cover crops the sides, the part of the photo near that point stays in view. The images lesson works the same arithmetic through for object-position.
  • background-repeat tiles the image (repeat, the default), or shows it once (no-repeat); space and round fit a whole number of tiles.
  • background-attachment: fixed pins 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:

Shorthands, borders, corners and shadows, measured HTML · decoration-lab.html
<!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:

Five ways to write gradient text, measured HTML · gradient-text-lab.html
<!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 text with 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-image without a url() to none, 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 sets color: CanvasText fixed it in both.
Gradient text that survives every case above CSS · gradient-text.css
/* 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.

Color Contrast Checker (WCAG 2.2) Check each stop of a text gradient against its background.

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 actually draws JavaScript · radii.mjs
// 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

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.

CSS Border Radius Generator Shape each corner, elliptical radii included, and copy the CSS.

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 from two background layers CSS · gradient-border.css
/* 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:

The predicted fade of a 20px blur JavaScript · blur_profile.mjs
// 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

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 CSS · elevation.css
/* 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.

CSS Box Shadow Generator Stack shadow layers visually and copy the value into a token.

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.

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-color is always at the bottom and can only be written in the last layer of the shorthand.
  • background resets every background longhand you leave out, and border resets border-image; change one part with its longhand.
  • background-origin decides where a layer is positioned, background-clip where it is painted. For gradient text, use one layer, no text shadow and a forced-colours rule that restores the text colour.
  • border-radius scales 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.

  1. Question 1 of 7 What does an element with both classes show?

    Read the code, then choose one answer.

    .banner { background-image: url("stall.jpg"); background-size: cover; }
    .banner.sale { background: #fde3ec; }
    Show the answer to question 1

    Answer: Only the pink colour, because the shorthand set background-image back to none.

    background resets every background longhand it does not mention, background-image included, so the second rule removes the photo. To change only the colour, write background-color: #fde3ec.

  2. Question 2 of 7 In background: url("logo.svg") right 16px top 16px no-repeat, linear-gradient(#123, #456);, which layer is drawn on top?

    Choose one answer.

    Show the answer to question 2

    Answer: The logo, because the first layer listed is closest to the reader.

    Background layers are painted from the last to the first, so the first one listed ends up on top. Write an overlay or a logo before the image it sits on.

  3. Question 3 of 7 A button is 200 px wide and 40 px tall, with border-radius: 9999px. How many pixels is the radius of each corner as the browser draws it?

    Type a number.

    Show the answer to question 3

    Answer: 20 px

    The radii on the left side add up to 19,998 px on a 40 px side, so every radius is multiplied by 40 ÷ 19,998. Each corner becomes 20 px, half the height, which is a pill.

  4. Question 4 of 7 A card has border-radius: 12px and border-image: linear-gradient(90deg, #b0124f, #b34700) 1. What do its corners look like?

    Choose one answer.

    Show the answer to question 4

    Answer: The gradient border has square corners; border-radius does not round a border image.

    Border images are not affected by border-radius, so the corners stay square, although the background inside is still clipped to the curve. Draw a rounded gradient border with two background layers instead.

  5. Question 5 of 7 A 300 px wide box sits in a 300 px wide scroll container. Which of these make the container scroll sideways?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • border: 40px solid
    • padding-inline: 40px

    Borders and padding are part of the box, so they make it wider than its container. Shadows and outlines are painted outside the box without taking space and add no scrollable overflow, as the lab measured in all three engines.

  6. Question 6 of 7 Put what one element paints in order, from the back to the front.

    Give each item its position, from 1 (first).

    Show the answer to question 6

    Answer:

    1. Outer box-shadows
    2. background-color
    3. Background image layers
    4. Inset box-shadows
    5. The border
    6. The content
    7. The outline

    Outer shadows sit behind the background, inset shadows above it and below the border, and the outline is drawn over everything else.

  7. Question 7 of 7 Gradient text written with color: transparent and background-clip: text is invisible for a user of a contrast theme in Firefox. What fixes it?

    Choose one answer.

    Show the answer to question 7

    Answer: @media (forced-colors: active) { .headline { color: CanvasText; } }

    Forced colours mode removes the gradient, and Firefox kept the transparent text colour, so nothing was painted. Restoring a system text colour in a forced-colors rule fixed it. A text shadow covers the gradient in normal mode and a second layer breaks the clip in Firefox; @supports matches in Firefox, so it changes nothing here.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.