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

See also

Sources

Authoritative references this article was fact-checked against.

TagsCSSfilterbackdrop-filterdrop-shadowblurgrayscalefrosted glass

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts