TechEarl

CSS Filters: blur, grayscale, brightness, and drop-shadow

A practical guide to the CSS filter property: blur, grayscale, brightness, contrast, saturate, sepia, hue-rotate, invert, opacity, and drop-shadow. How to chain them, when drop-shadow beats box-shadow, frosted glass with backdrop-filter, and the performance traps.

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
A practical guide to the CSS filter property: blur, grayscale, brightness, drop-shadow, chaining, backdrop-filter frosted glass, and performance.

The CSS filter property applies graphical effects, blur, color shifts, shadows, directly to an element and everything it contains, in one line:

css
.thumbnail {
  filter: grayscale(100%);
}

That is the whole feature for the common case. filter is Baseline now, unprefixed, on every current engine, and it works on any element: images, video, text, an entire <div>. Back when I first wrote this, filters were a WebKit-nightly toy that could not even touch <video>. None of that is true anymore. The rest of this page is the function-by-function reference, plus the two things people actually get wrong: when drop-shadow() beats box-shadow, and how not to wreck your frame rate.

The filter functions

Each filter function takes one element and returns a modified version of it. The full set:

css
.demo {
  filter: blur(4px);          /* gaussian blur, takes a length */
  filter: brightness(150%);   /* 100% = unchanged, 0% = black */
  filter: contrast(80%);      /* 100% = unchanged, 0% = grey */
  filter: grayscale(100%);    /* 0% = original, 100% = fully grey */
  filter: saturate(200%);     /* 100% = unchanged, 0% = grayscale */
  filter: sepia(60%);         /* warm photo-tint, 0% to 100% */
  filter: hue-rotate(90deg);  /* rotate every hue on the wheel */
  filter: invert(100%);       /* photo negative */
  filter: opacity(50%);       /* like opacity:, but composites with siblings */
}

A few traps in the value ranges. The percentage functions (brightness, contrast, saturate, grayscale, sepia, invert, opacity) also accept a unitless decimal, so grayscale(0.3) and grayscale(30%) are the same thing. What does not work is passing a length where a number belongs: grayscale(3px) is invalid and the whole declaration is dropped silently. blur() is the opposite, it wants a length (blur(4px)), never a number or a percentage. hue-rotate() takes an angle (deg, turn, rad).

brightness and saturate have no upper bound, so brightness(300%) and saturate(400%) are legal and useful for a punchy hover state. opacity() as a filter looks redundant next to the opacity property, and for a single element it usually is, but because it is a filter it can be GPU-composited alongside the others in a chain.

drop-shadow()

drop-shadow() is the function that earns this property its keep, because it does something no other CSS can:

css
.logo {
  filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.4));
}

The argument list is offset-x offset-y blur-radius color, the same shape as the first four values of box-shadow.

The difference that matters: box-shadow traces the element's bounding box, drop-shadow() traces its actual painted shape. Give a transparent PNG, an SVG icon, or an element clipped with clip-path a box-shadow and you get a rectangle floating behind the visible art. Give it drop-shadow() and the shadow follows the alpha channel: the silhouette of the logo, the points of the star, the curve of the speech bubble. That is the entire reason to reach for it.

Two things drop-shadow() cannot do that box-shadow can. There is no spread value (the fourth box-shadow length), and there is no inset keyword, so it is outer-shadow only. And for the same input values the two render slightly differently, box-shadow reads heavier, so do not expect a pixel match if you swap one for the other.

Chaining multiple filters

Pass a space-separated list and the browser applies them left to right. Order changes the result, this is a pipeline, not a set:

css
.faded-hero {
  filter: grayscale(50%) brightness(0.8) blur(2px);
}

/* a card that pops on hover */
.card {
  filter: saturate(80%) brightness(0.95);
  transition: filter 200ms ease;
}
.card:hover {
  filter: saturate(120%) brightness(1.05);
}

Because order matters, blur() then drop-shadow() shadows the already-blurred shape, while drop-shadow() then blur() blurs the shadow too. Decide which you want.

For a dark-mode hack, invert(1) hue-rotate(180deg) flips lightness while leaving hues roughly intact, handy for inverting a diagram or a code screenshot without turning every color into its complement.

backdrop-filter: frosted glass

filter affects the element itself. backdrop-filter affects what is behind it, the pixels showing through a translucent element, which is how you get the frosted-glass panel that is everywhere in modern UI:

css
.glass-panel {
  background: rgba(255, 255, 255, 0.15);
  -webkit-backdrop-filter: blur(12px) saturate(160%);
  backdrop-filter: blur(12px) saturate(160%);
}

The element needs a semi-transparent background, otherwise there is nothing to see the blur through. Two caveats from shipping this in production:

  • Keep the -webkit- prefix. Safari supported backdrop-filter for years only behind -webkit-, dropping the prefix in Safari 18 (2024). Older iOS and macOS traffic still relies on the prefixed form, so ship both: list the prefixed line first, the unprefixed second.
  • A CSS custom property in the prefixed Safari path does not resolve, so -webkit-backdrop-filter: blur(var(--blur)) quietly fails there. Use a literal value for backdrop-filter rather than a var().

If you want the panel to degrade gracefully where it is unsupported, gate it on a feature query and keep a solid fallback background, which is the kind of progressive enhancement CSS feature queries with @supports are built for.

Performance: animate sparingly

Filters are GPU-accelerated, but they are not free. blur() in particular is expensive, the browser has to sample a neighborhood of pixels for every output pixel, and backdrop-filter is worse because it re-samples the backdrop on every paint. A static filter on a handful of elements is fine. Animating filter: blur() across a large area, or stacking many blurred glass panels that overlap, is where you watch the frame rate fall off a cliff on a mid-range phone.

Two rules I hold to. First, prefer animating opacity and transform (the two properties the compositor can handle without a repaint) over animating filter when you have the choice. Second, if you must animate a filter, hint it with will-change: filter so the browser promotes the element to its own layer ahead of time, but remove the hint when the animation ends, a permanent will-change wastes memory. And respect motion preferences: gate any filter animation behind @media (prefers-reduced-motion: no-preference).

Quick reference

FunctionArgumentIdentity (no change)Notes
blur()lengthblur(0)length only, never a number
brightness()number/%100%no upper bound
contrast()number/%100%0% = solid grey
grayscale()number/%0%100% = fully grey
saturate()number/%100%over 100% oversaturates
sepia()number/%0%warm photo tint
hue-rotate()angle0degrotates the color wheel
invert()number/%0%100% = negative
opacity()number/%100%composites in the filter chain
drop-shadow()offsets, blur, colornonefollows the alpha shape; no spread, no inset

FAQ

box-shadow draws the shadow around the element's rectangular bounding box. The drop-shadow() filter follows the element's actual painted shape (its alpha channel), so a transparent PNG, an SVG icon, or a clip-path shape casts a shadow in its real silhouette rather than a rectangle. drop-shadow() has no spread value and no inset, so it is outer-only.

Yes. Space-separate them in one filter declaration and the browser applies them left to right, like filter: grayscale(50%) brightness(0.8) blur(2px). Order changes the output, so blurring before adding a drop-shadow is not the same as the reverse.

You probably passed a length where a number belongs, grayscale(3px) is invalid and the whole declaration is dropped. Use a number or a percentage instead: grayscale(0.3) or grayscale(30%). Conversely, blur() requires a length and rejects a bare number.

Keep it for now. Safari only dropped the prefix in Safari 18 (2024), so older iOS and macOS visitors still need -webkit-backdrop-filter. List the prefixed line first and the unprefixed second. Note that a CSS custom property does not resolve in Safari's prefixed path, so use a literal value rather than a var() there.

Static filters on a few elements are fine. The cost shows up when you animate blur() over a large area or stack overlapping backdrop-filter panels, both re-sample many pixels per frame. Prefer animating opacity and transform, use will-change: filter only for the duration of an animation, and respect prefers-reduced-motion.

See also

Sources

Authoritative references this article was fact-checked against.

TagsCSSfilterbackdrop-filterdrop-shadowblurgrayscalefrosted glass

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts