filter, backdrop-filter and Blend Modes: Processing Images and Backdrops with CSS
filter processes an element and all its children as one image, applying its functions from left to right, while backdrop-filter processes what is behind the element; blend modes combine the colors of layers, and isolation: isolate keeps that blending inside a component. The post measures drop-shadow against box-shadow, a sold-out cover, a frosted filter bar with a fallback background and a duotone banner built with background-blend-mode in Bookstore.
Version note: This post follows the Filter Effects Module Level 1 and Level 2 and the Compositing and Blending Level 1 specifications and the November 2026 state of Baseline (web-features 3.46.0); the examples were run in Chromium 158 and WebKit 27.2 (Playwright 1.65), and Firefox support comes from web-features and MDN browser-compat-data 8.1.9.
Guess First
In the catalog of Bookstore, the online bookstore used as the shared example across this site's series, we want to show sold-out books faded, put a filter bar over the catalog that stays readable while content scrolls under it, and turn the campaign banner on the home page into a two-color look. All of these mean processing an image, or whatever is behind an element, with CSS. First a small experiment: we gave a square with a red background two filters in two different orders.
/* 1) filter functions run from left to right */
#gray-sepia { filter: grayscale(1) sepia(1); }
#sepia-gray { filter: sepia(1) grayscale(1); }
Does the red corner turn the same color in both squares? If one becomes gray and the other brownish, which is which? Write your answer down.
filter Functions and Their Order
The filter property takes space-separated functions: grayscale(), sepia(), saturate(), hue-rotate(), invert(), brightness(), contrast(), opacity(), blur() and drop-shadow(). Amounts are given as numbers or percentages; grayscale(1) and grayscale(100%) are the same. The functions run from left to right, each one on the output of the previous one:
node scripts/07-filters.mjs
--- chromium
original filter none red corner: rgb(220, 38, 38)
gray-sepia filter grayscale(1) sepia(1) red corner: rgb(104, 92, 72)
sepia-gray filter sepia(1) grayscale(1) red corner: rgb(110, 110, 110)
--- webkit
original filter none red corner: rgb(220, 38, 38)
gray-sepia filter grayscale(1) sepia(1) red corner: rgb(102, 91, 70)
sepia-gray filter sepia(1) grayscale(1) red corner: rgb(108, 108, 108)
The order changes the result. grayscale(1) sepia(1) first turns the color gray, then adds a sepia tone: the corner is brownish (104, 92, 72). sepia(1) grayscale(1) applies sepia first, then removes all color: the corner is plain gray (110, 110, 110). Think of it like the transform functions in the previous post: the list is a pipeline and the last step decides the result. The two engines differ by a unit or two; the rounding of color conversions can vary between engines, so use a tolerance instead of exact equality in tests.
If the built-in functions are not enough, filter: url(#id) connects to an SVG <filter> element on the page; effects such as color matrices and noise come from there, and they have been in Baseline for a very long time. The filter() function, which applies a filter to an image and uses the result as a background-image, exists only in Safari and is not Baseline.
drop-shadow() vs box-shadow
In the previous post we used box-shadow as the shadow of a box. When an image has a transparent background (a PNG or an SVG) and the shadow must fall from the shape itself rather than from the box, you use filter: drop-shadow(). To see the difference, I shadowed a picture of a tilted book (a transparent PNG) both ways and counted the red shadow pixels:
/* 2) box-shadow follows the box, drop-shadow follows the pixels */
.tilted { display: block; inline-size: 180px; block-size: 180px; margin-block-end: 20px; }
#box-shadow { box-shadow: 10px 10px 0 #b91c1c; }
#drop-shadow { filter: drop-shadow(10px 10px 0 #b91c1c); }
box-shadow red shadow pixels inside the box: 0 outside: 3500
drop-shadow red shadow pixels inside the box: 1964 outside: 20
All 3500 pixels of the box-shadow are outside the 180×180 box: the shadow is an L shape to the right of and below the box. There is no shadow in the transparent area inside the box, because an outer shadow is painted only outside the border box. drop-shadow() paints almost all of its 1964 pixels inside the box, right next to the edges of the tilted book; only 20 pixels spill out, because the opaque pixels of the image come almost to the edge of the box. Two more differences: drop-shadow() has no spread and no inset, and because it is a filter, it follows the combined shape of everything inside the element, text included.
The Sold-out Book: Where Does the filter Go?
The cover of a sold-out book should look gray and faded, with a red "Sold out" badge on top. The first idea is to put the filter on the whole card:
/* 3) filter on the card also turns its badge gray */
.lab-card { display: inline-block; padding: 8px; background: #ffffff; }
.lab-card .badge { display: block; padding: 4px 8px; background: #b91c1c; color: #ffffff; }
#card-filter .lab-card { filter: grayscale(1); }
#img-filter .lab-card img { filter: grayscale(1); }
card-filter badge: rgb(61, 61, 61) cover: rgb(77, 77, 77)
img-filter badge: rgb(185, 28, 28) cover: rgb(77, 77, 77)
With the filter on the card, the badge turns gray too: its background dropped from the red 185, 28, 28 to the gray 61, 61, 61. filter processes the whole element, with all its children, as a single image; there is no way to exclude one child. With the filter on the image only, the cover turns the same gray and the badge stays red. That is what we did in Bookstore:
<li class="book-card is-sold-out">
<span class="badge">Sold out</span>
<div class="cover"><img src="img/cover-hobbit.webp" alt="" width="240" height="360"></div>
/* sold out: the cover fades, the badge keeps its color */
.book-card.is-sold-out .cover img {
filter: grayscale(1);
opacity: 0.6;
}
node scripts/07-pages.mjs
--- chromium
sold out cover: filter grayscale(1), opacity 0.6
sold out badge pixel: rgb(185, 28, 28)
filter bar after scroll: y=0, backdrop-filter blur(12px), background rgba(249, 250, 251, 0.7)
filter bar pixel over the cover: rgb(181, 203, 194)
banner without duotone red-dominant pixels in the top 60px: 10812 (normal, normal)
banner with duotone red-dominant pixels in the top 60px: 0 (normal, lighten, multiply, luminosity)
--- webkit
sold out cover: filter grayscale(1), opacity 0.6
sold out badge pixel: rgb(185, 28, 28)
filter bar after scroll: y=0, backdrop-filter blur(12px), background rgba(249, 250, 251, 0.7)
banner without duotone red-dominant pixels in the top 60px: 10766 (normal)
banner with duotone red-dominant pixels in the top 60px: 0 (normal, lighten, multiply, luminosity)
--- chromium, forced colors
banner: background-image none, none, none, none, background-color rgb(255, 255, 255), color rgb(0, 0, 0)
The first two lines are about the sold-out card: the cover has grayscale(1) and an opacity of 0.6, and the badge pixel is still red (185, 28, 28). Note two things here. First, we do not tell the state with color alone; the "Sold out" text is visible and read by screen readers. Second, a filter changes how something looks, not how it behaves. If this card had an "Add to cart" button, turning the cover gray would not disable the button; it would need the disabled attribute.
Like transform, any filter value other than none makes the element a new stacking context and the containing block for position: fixed elements inside it. Details and a measurement are in post 04 of the CSS3 series; putting the filter on a small element (here the img) keeps those side effects narrow as well.
A Frosted Glass Filter Bar with backdrop-filter
filter processes the element itself; backdrop-filter processes what is behind it. Together with a semi-transparent background it gives the "frosted glass" effect that blurs the content behind. To see how much it matters, I put a semi-transparent white bar over 4px black and white stripes and read a 16-pixel row from the middle of the bar:
/* 4) frosted bar over 4px stripes */
.stripes { position: relative; inline-size: 200px; block-size: 100px;
background: repeating-linear-gradient(90deg, #000000 0 4px, #ffffff 4px 8px); }
.bar { position: absolute; inset: 30px 0 auto; block-size: 40px; background: rgb(255 255 255 / 0.5); }
#frosted .bar { backdrop-filter: blur(8px); }
plain backdrop-filter none row min 128, max 255
frosted backdrop-filter blur(8px) row min 182, max 201
Without the filter the values swing between 128 and 255: the stripes show through the bar unchanged, and text on that background is hard to read. With blur(8px) the values squeeze between 182 and 201; the background becomes an almost flat gray. I show this measurement from Chromium only: Playwright's WebKit build did not paint backdrop-filter in screenshots, while the computed value is blur(8px) in both engines.
backdrop-filter became Baseline newly available in September 2024, when Safari 18 shipped it without a prefix; it will take a while longer to become widely available. From Safari 9 to 17 it works as -webkit-backdrop-filter. That is why the sticky filter bar on Bookstore's catalog page is written in two layers: the default background is opaque enough to be readable on its own, and when blur is supported, the background becomes a bit more transparent:
.filter-bar {
position: sticky;
inset-block-start: 0;
z-index: 5;
display: flex;
gap: 0.5rem;
padding-block: 0.5rem;
/* readable on its own: this is what browsers without backdrop-filter show */
background: rgb(249 250 251 / 0.95);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.filter-bar {
background: rgb(249 250 251 / 0.7);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
}
.filter-bar button {
padding: 0.25rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
font: inherit;
}
.filter-bar button[aria-pressed="true"] { border-color: var(--color-link); color: var(--color-link); }
The third line of the page measurement: after scrolling 300px the bar is still at y=0, and both engines apply blur(12px) and the background at 0.7 opacity. In Chromium, the pixel where the bar covers the green Middlemarch cover is (181, 203, 194): the cover's color shows through as a light tint, but its text and lines do not. Testing both spellings in the @supports condition matters; testing only backdrop-filter would give Safari 17 users the opaque background despite the prefixed support (which is not a problem, just less polished). Sticky positioning and the z-index value itself are covered in post 04 of the CSS3 series.
Blend Modes: mix-blend-mode and isolation
A blend mode decides how a layer's color combines with the color behind it: multiply multiplies the colors and darkens (white has no effect), screen does the opposite and lightens (black has no effect), overlay mixes the two; lighten and darken pick the larger or smaller value in each channel, and luminosity combines the brightness of the top layer with the color of the one below. mix-blend-mode blends an element with everything behind it. The "everything behind it" part matters:
<section class="page" id="blended"><div class="group"><div class="chip"></div></div></section>
<section class="page" id="isolated"><div class="group"><div class="chip"></div></div></section>
/* 6) mix-blend-mode blends with everything behind it, isolation limits that */
.page { padding: 20px; background: #f59e0b; }
.chip { inline-size: 60px; block-size: 30px; background: #1d4ed8; mix-blend-mode: screen; }
#isolated .group { isolation: isolate; }
blended chip: rgb(246, 188, 218)
isolated chip: rgb(29, 78, 216)
The blue chip (29, 78, 216) blends with the orange of the page through screen and becomes a light pinkish color (246, 188, 218); the transparent .group in between does not stop it. With isolation: isolate on .group, the group becomes a new stacking context and the chip blends only with what is inside the group. The group is empty, so the chip keeps its own color. If you do not want a blend effect inside a component to leak into the page background, give the component's root element isolation: isolate.
A Duotone Campaign Banner
Duotone is a two-color look that maps the dark tones of an image to one color and its light tones to another. background-blend-mode blends an element's background layers with each other and leaves the element's text alone. We built the duotone with three layers; to see the logic, I used a gradient from black to white instead of an image:
/* 5) duotone with background layers: dark → navy, light → pale amber */
#duotone { inline-size: 200px; block-size: 40px; background-color: #ffffff;
background-image: linear-gradient(#1e3a8a, #1e3a8a), linear-gradient(#fde68a, #fde68a),
linear-gradient(to right, #000000, #ffffff);
background-blend-mode: lighten, multiply, luminosity; }
--- chromium
duotone left rgb(30, 58, 138) middle rgb(127, 116, 138) right rgb(251, 228, 138)
--- webkit
duotone left rgb(30, 58, 138) middle rgb(128, 116, 138) right rgb(252, 229, 138)
The layers blend from the bottom up. The bottom gradient sits on the white background with luminosity and turns into grays (the background has no color, so only brightness remains). The pale amber layer above it multiplies that gray with multiply: black stays black, white becomes pale amber. The navy layer on top picks the larger value in each channel with lighten: black becomes navy (30, 58, 138), pale amber stays as it is. The result goes from navy on the left through an in-between tone of (127, 116, 138) in the middle to pale amber (251, 228, 138) on the right. The rule: every channel of the light color must be greater than or equal to the dark color's; otherwise lighten picks the dark color in that channel. In my first try I used orange (#f59e0b) and the right end came out as (243, 157, 138), because the blue channel of orange (11) is smaller than navy's (138).
On the home page banner, the darkening gradient that keeps the text readable sits on top of the same three layers with a normal blend:
.promo-banner {
display: grid;
align-content: end;
min-block-size: 16rem;
padding: 2rem;
border-radius: 0.75rem;
color: #ffffff;
/* shown until the image arrives, and if it never does */
background-color: #1e293b;
/* first layer is on top: the overlay keeps the text readable */
background-image:
linear-gradient(to top, rgb(15 23 42 / 0.9), rgb(15 23 42 / 0.3) 70%),
linear-gradient(#1e3a8a, #1e3a8a),
linear-gradient(#fde68a, #fde68a),
image-set(
url("../../img/banner-1x.avif") 1x type("image/avif"),
url("../../img/banner-2x.avif") 2x type("image/avif"),
url("../../img/banner-1x.webp") 1x type("image/webp"),
url("../../img/banner-2x.webp") 2x type("image/webp")
);
/* duotone: the photo turns gray (luminosity), light parts → pale amber (multiply),
dark parts → navy (lighten); the overlay is painted normally */
background-blend-mode: normal, lighten, multiply, luminosity;
background-size: cover;
background-position: center;
}
The page measurement's banner lines: the top 60 pixels of the banner before this post (darkening plus image) had 10812 red-dominant pixels; with the duotone the number is 0. The reds, blues and greens of the book spines all turn into tones between navy and amber. The text stays white, because blending happens only between background layers. Before the image loads, or if it never does, the dark background-color and the color layers still leave a readable background.
In Production
blur() and backdrop-filter are expensive effects: the cost grows with the blur radius and the area. backdrop-filter has an extra cost: the blur is recomputed whenever the content behind it changes (when the page scrolls, when an animation runs behind it). For a bar that covers a strip of the screen this is acceptable; a large blur on an overlay that covers the whole page can make scrolling stutter on low-powered devices. Keep the effect to small areas and small radii, and try it on a real phone with the DevTools performance panel.
A filter can be animated (for example from grayscale(1) to grayscale(0) on hover); when both sides have the same list of functions, the browser interpolates the values one by one. If the lists differ, the result may not be what you expect. The general rules of animation are in post 13 of the CSS3 series.
The last line of the page measurement shows forced colors mode (like Windows' high contrast themes): Chromium sets all four background layers of the banner to none, and turns the background white and the text black. The duotone disappears completely, but since the banner's information is in its text, nothing is lost. A design that carries information only through a filter or blend effect (for example, saying "out of stock" only with a gray cover) loses it in this mode and for users who have trouble telling colors apart; that is why the visible "Sold out" text matters.
Common Pitfalls
First, putting a filter on a whole card and noticing later that the badge, button or icon inside it turned gray as well. Put the filter on the smallest element that needs to change.
Second, giving an element a "disabled" look with a filter without actually disabling it. A gray button can still be clicked and focused with the keyboard; it needs the disabled attribute or the right ARIA state.
Third, writing no fallback background for backdrop-filter. In a browser without support, text and images show through the semi-transparent bar unchanged, and the text on the bar becomes unreadable.
Fourth, forgetting that filter and backdrop-filter create a stacking context and a containing block. A position: fixed element inside, or a dropdown that needs to overflow, behaves differently.
Fifth, assuming the effect of mix-blend-mode stays inside the component. The element blends with everything behind it up to its nearest stacking context; if you want a boundary, write isolation: isolate.
Baseline Status
node scripts/baseline.mjs filter backdrop-filter mix-blend-mode background-blend-mode isolation svg-filters filter-function
filter widely 2019-03-07 Chrome 53, Firefox 35, Safari 9.1, iOS 9.3
backdrop-filter newly 2024-09-16 Chrome 76, Firefox 103, Safari 18, iOS 18
mix-blend-mode widely 2022-07-15 Chrome 41, Firefox 32, Safari 8, iOS 8
background-blend-mode widely 2022-07-15 Chrome 35, Firefox 30, Safari 8, iOS 8
isolation widely 2022-07-15 Chrome 41, Firefox 36, Safari 8, iOS 8
svg-filters widely 2018-01-29 Chrome 5, Firefox 3, Safari 6, iOS 6
filter-function not Baseline Chrome -, Firefox -, Safari 9.1, iOS 9.3
| Feature | Status | Note |
|---|---|---|
filter (functions, drop-shadow()) | Widely available | Functions apply from left to right |
backdrop-filter | Newly available (2024-09) | With the -webkit- prefix in Safari 9–17 |
mix-blend-mode, background-blend-mode | Widely available | The plus-darker value is Safari only |
isolation | Widely available | Keeps blending inside a component |
SVG filters (url(#id)) | Widely available | Custom effects such as color matrices and noise |
The filter() function | Not Baseline | Safari only |
A Small Experiment
★☆☆ Give the lab square brightness(1.5) contrast(2) and contrast(2) brightness(1.5); add two lines to 07-filters.mjs and measure which color the red corner turns in each order.
★★☆ Make the sold-out cover regain its color on hover (grayscale(0), opacity: 1) with a transition that respects prefers-reduced-motion. Measure the cover pixel before and after the hover with 07-pages.mjs.
★★★ Build the duotone with Bookstore's accent red (#b91c1c) and a light cream instead of navy and amber. First pick a cream that follows the channel rule, then verify with the lab gradient that the left, middle and right ends land on the colors you expect.
In the next post we clip and mask elements: the basic shapes of clip-path and shape(), mask-image with gradients and SVG, and shape-outside for text that flows around the cover on the book page.