
CSS Filters: Blur, Grayscale, and Beyond
Before CSS filters, turning a color logo gray on hover meant exporting two images. Blurring a background meant preparing a blurred version in an image editor. Tinting icons for a dark theme meant yet another asset. The filter property changed all of that by letting the browser apply graphical effects to any element at render time.
In this guide we'll cover every built-in filter function, what order they run in, how drop-shadow() differs from box-shadow, how to write custom SVG filters when the built-ins aren't enough, and how to keep filters fast.
How filter Works
The filter property takes one or more filter functions and applies them to the rendered element, including its children, text, borders, and backgrounds. The element is painted first, then the effects are applied to the resulting pixels.
.photo {
filter: grayscale(100%);
}
A few things to know from the start:
- Filters affect the whole element and its descendants. You can't un-filter a child.
- Any value other than
nonecreates a new stacking context and a containing block for fixed and absolutely positioned descendants. That means aposition: fixedchild inside a filtered element is positioned relative to that element, not the viewport. - Filters don't change layout. A blurred element takes up exactly the same space as before, even though its pixels spread beyond its box.
The Filter Functions
There are ten built-in functions. Most take a number or percentage, where 1 equals 100%.
blur()
Applies a Gaussian blur. The value is the standard deviation as a length, not a percentage.
.spoiler {
filter: blur(6px);
transition: filter 0.3s ease;
}
.spoiler:hover,
.spoiler:focus-within {
filter: none;
}
Blur spreads pixels outward, so edges soften and bleed past the element's box. If you're blurring an image inside a container with overflow: hidden, the edges will look faded because transparent pixels get mixed in. A common fix is to scale the image up slightly:
.hero-bg {
overflow: hidden;
}
.hero-bg img {
filter: blur(20px);
transform: scale(1.1);
}
brightness()
Multiplies the color values. 0 is black, 1 is unchanged, and values above 1 brighten.
.card img {
transition: filter 0.25s ease;
}
.card:hover img {
filter: brightness(1.15);
}
contrast()
Adjusts the difference between light and dark. 0 gives solid gray, 1 is unchanged, and higher values increase contrast.
.scan {
filter: contrast(1.4);
}
grayscale()
Removes color. 100% is fully gray.
.logo-wall img {
filter: grayscale(100%);
opacity: 0.7;
transition:
filter 0.3s ease,
opacity 0.3s ease;
}
.logo-wall img:hover {
filter: grayscale(0%);
opacity: 1;
}
That's the classic "client logos" pattern: muted by default, full color on hover.
sepia()
Converts to warm brown tones for a vintage look.
.archive-photo {
filter: sepia(80%);
}
saturate()
Boosts or reduces color intensity. 0 is fully desaturated (like grayscale(100%)), 1 is unchanged, and 2 doubles saturation.
.vivid {
filter: saturate(1.6);
}
hue-rotate()
Rotates every color around the color wheel by an angle.
.theme-variant {
filter: hue-rotate(120deg);
}
It's a quick way to preview color variations of an illustration, though the result isn't perceptually precise, so don't rely on it for brand colors.
invert()
Inverts colors. 100% produces a full negative.
.diagram {
filter: invert(100%) hue-rotate(180deg);
}
Pairing invert() with a 180-degree hue-rotate() is a well-known trick for adapting black-on-white diagrams to dark mode: the inversion flips light and dark, and the hue rotation roughly restores the original hues.
opacity()
Works like the opacity property, but as a filter function, so it can be combined in a chain. The opacity property is usually better for simple fades because it's cheaper and well-optimized.
drop-shadow()
Draws a shadow that follows the element's actual visible shape, including transparency.
.icon {
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 0.35));
}
This deserves its own section.
drop-shadow() vs. box-shadow
box-shadow always follows the element's box (respecting border-radius). drop-shadow() follows the rendered pixels. For a transparent PNG, an SVG icon, or a shape made with clip-path, that difference is huge:
<img class="product box" src="/images/sneaker.png" alt="Blue running shoe" />
<img class="product drop" src="/images/sneaker.png" alt="Blue running shoe" />
.product {
width: 240px;
}
.box {
box-shadow: 0 12px 20px rgb(0 0 0 / 0.3);
}
.drop {
filter: drop-shadow(0 12px 20px rgb(0 0 0 / 0.3));
}
The first image gets a rectangular shadow around its bounding box. The second gets a shadow shaped like the shoe.
Other differences to keep in mind:
drop-shadow()has no spread radius and no inset option.- The blur value in
drop-shadow()is interpreted differently frombox-shadow, so the same number often looks sharper or softer. Tune by eye. - You can chain multiple
drop-shadow()functions to layer shadows.
A handy application is giving speech-bubble shapes a shadow. If you draw the tail with a pseudo-element, box-shadow on the bubble won't include it, but drop-shadow() on the parent shadows both as one shape:
.bubble {
position: relative;
padding: 1rem 1.25rem;
border-radius: 12px;
background: #ffffff;
filter: drop-shadow(0 6px 10px rgb(15 23 42 / 0.2));
}
.bubble::after {
content: "";
position: absolute;
bottom: -10px;
left: 24px;
border-width: 10px 10px 0;
border-style: solid;
border-color: #ffffff transparent transparent;
}
Combining Filters
Separate functions with spaces. They're applied in order, left to right, and each works on the output of the previous one:
.moody {
filter: grayscale(60%) contrast(1.2) brightness(0.9);
}
Order matters. For example, drop-shadow() followed by blur() blurs both the element and its shadow, while blur() followed by drop-shadow() casts a shadow from the already blurred element. Similarly, grayscale() before sepia() gives a different tone than the reverse.
A common gotcha is overriding: setting filter again replaces the whole list. If an image has filter: grayscale(100%) and you add filter: blur(4px) on hover, it loses the grayscale. Include everything you want in each state:
.thumb {
filter: grayscale(100%) blur(0);
transition: filter 0.3s ease;
}
.thumb:hover {
filter: grayscale(0%) blur(0);
}
.thumb.is-loading {
filter: grayscale(100%) blur(4px);
}
Keeping the same functions in the same order in every state also ensures transitions interpolate smoothly. If the lists don't match, the browser pads missing functions with their default values where it can.
Driving Filters with Custom Properties
Custom properties make filter presets easy to maintain:
.photo {
--gray: 0%;
--bright: 1;
--blur: 0px;
filter: grayscale(var(--gray)) brightness(var(--bright)) blur(var(--blur));
transition: filter 0.3s ease;
}
.photo.is-disabled {
--gray: 100%;
--bright: 0.8;
}
.photo.is-background {
--blur: 8px;
--bright: 0.6;
}
Practical Recipes
Dim a Background Behind a Modal
body.modal-open .page-content {
filter: blur(3px) brightness(0.7);
transition: filter 0.2s ease;
}
Apply this to a wrapper around your content, not to body or the element containing the modal itself, because the modal would be blurred too.
Colorize a Monochrome Icon
Filters can recolor a black SVG or PNG icon, though the chain is fiddly:
.icon-black-to-sky {
filter: invert(62%) sepia(83%) saturate(1520%) hue-rotate(170deg)
brightness(101%) contrast(96%);
}
Chains like this are usually generated with an online "CSS filter generator" tool. They work, but if you control the icon, a better approach is an inline SVG with fill: currentColor, or a CSS mask with a background color. Both give exact colors.
Grayscale Out-of-Stock Products
.product-card[data-stock="0"] img {
filter: grayscale(100%) opacity(0.6);
}
Invert Dark Mode for Embedded Content
@media (prefers-color-scheme: dark) {
.docs img.diagram {
filter: invert(1) hue-rotate(180deg);
}
}
Only use this for diagrams and screenshots of text. Photos look strange when inverted.
Going Beyond: SVG Filters
The ten built-in functions are shortcuts for SVG filter primitives. When you need more, you can write your own filter in SVG and reference it with url():
<svg width="0" height="0" style="position: absolute" aria-hidden="true">
<filter id="duotone-sky">
<feColorMatrix
type="matrix"
values="0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0.33 0.33 0.33 0 0
0 0 0 1 0"
/>
<feComponentTransfer>
<feFuncR type="table" tableValues="0.06 0.22" />
<feFuncG type="table" tableValues="0.09 0.74" />
<feFuncB type="table" tableValues="0.16 0.97" />
</feComponentTransfer>
</filter>
</svg>
<img
class="duotone"
src="/images/team.jpg"
alt="The team at the summer offsite"
/>
.duotone {
filter: url(#duotone-sky);
}
The feColorMatrix converts the image to grayscale, then feComponentTransfer maps dark values to a deep navy and light values to sky blue. The result is a duotone effect that no built-in function can produce.
Other SVG primitives open up even more:
feTurbulenceplusfeDisplacementMapfor wavy, glitchy, or water-like distortions.feMorphologyfor thickening or thinning shapes, useful for outlined text effects.feGaussianBlurplusfeColorMatrixfor the "gooey" effect, where nearby blobs merge into one.
You can also mix SVG and built-in functions in one list:
.duotone-soft {
filter: url(#duotone-sky) contrast(1.1);
}
SVG filters referenced from CSS are supported in all modern browsers, but complex ones can be expensive. Test on real devices.
Performance and Accessibility
Filters run on the GPU in modern browsers, but some are costlier than others:
- Blur is the most expensive, and cost rises with the radius and the element's size. A 4px blur on a thumbnail is trivial. A 40px blur on a full-screen video is not.
- Animating filters forces the browser to recompute them every frame. Short hover transitions are fine. Constantly animated large blurs aren't.
- Color filters like
grayscale()andbrightness()are cheap. - Complex SVG filters can be slow.
feTurbulencein particular is expensive to animate.
For accessibility, remember that filters are purely visual:
- Never use
blur()to hide sensitive content. The content is still in the DOM, readable by screen readers, and can be un-blurred in DevTools. - If you use
grayscale()to indicate a disabled state, add a realdisabledattribute oraria-disabledas well. - Honor
prefers-reduced-motionfor animated filter effects, especially flashing brightness or hue cycling.
Browser Support
The filter property and all ten built-in functions are supported in every modern browser, with no prefixes needed. Referencing SVG filters with url() also works everywhere, though rendering details can vary slightly between engines, so compare complex SVG filters across browsers.
Conclusion
The filter property gives you a surprising amount of image editing power right in your stylesheet. blur(), grayscale(), brightness(), and friends handle everyday tasks like hover states, disabled styles, and background dimming. drop-shadow() shadows real shapes rather than boxes. And when the built-ins run out, SVG filters let you build duotones, distortions, and gooey effects.
Remember that functions apply in order, that each new filter declaration replaces the whole list, and that blur is the one to watch for performance. With those rules in mind, you can replace a stack of edited image assets with a few lines of CSS.


