Filters and visual effects

Understand the filter pipeline, use the primitives that earn their cost, and treat filters as the expensive feature they are.

How a filter chain works

<svg viewBox="0 0 240 120" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- Every primitive takes its input from a previous result by name
         (or SourceGraphic / SourceAlpha) and writes a named result. -->
    <filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
      <feGaussianBlur in="SourceAlpha" stdDeviation="3" result="blur"/>
      <feOffset in="blur" dx="0" dy="2" result="offset"/>
      <feFlood flood-color="#0f172a" flood-opacity="0.35" result="colour"/>
      <feComposite in="colour" in2="offset" operator="in" result="shadow"/>
      <feMerge>
        <feMergeNode in="shadow"/>
        <feMergeNode in="SourceGraphic"/>
      </feMerge>
    </filter>
  </defs>

  <rect x="60" y="35" width="120" height="50" rx="8" fill="#6d28d9" filter="url(#drop-shadow)"/>
</svg>
PrimitiveEffectCost
feGaussianBlurBlurHigh: scales with the radius and the area
feOffsetShift by a distanceCheap
feFloodFill an area with a colourCheap
feCompositeCombine two inputs with a Porter-Duff operatorModerate
feMergeLayer results togetherCheap
feColorMatrixRecolour, saturate, desaturateModerate
feTurbulenceProcedural noiseVery high
feDisplacementMapWarp using another image's channelsVery high
feDropShadowA blur, an offset and a merge in oneSame as its parts: high
feComponentTransferPer-channel curvesModerate
feMorphologyErode or dilateHigh
⚠️
A filter's default region is -10% to 110% of the element's bounding box. A blur or a shadow that extends further is clipped, which is the reason a drop shadow looks sliced off. Set x, y, width and height on the filter explicitly — -20% and 140% is a reasonable starting point.

The primitives worth knowing

<svg viewBox="0 0 320 120" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- Recolour without touching the source: the matrix is applied per channel. -->
    <filter id="sepia">
      <feColorMatrix type="matrix" values="
        0.393 0.769 0.189 0 0
        0.349 0.686 0.168 0 0
        0.272 0.534 0.131 0 0
        0      0     0     1 0"/>
    </filter>

    <!-- Desaturate completely -->
    <filter id="greyscale">
      <feColorMatrix type="saturate" values="0"/>
    </filter>

    <!-- Push contrast: two linear functions, one per half of the range -->
    <filter id="contrast">
      <feComponentTransfer>
        <feFuncR type="linear" slope="1.8" intercept="-0.4"/>
        <feFuncG type="linear" slope="1.8" intercept="-0.4"/>
        <feFuncB type="linear" slope="1.8" intercept="-0.4"/>
      </feComponentTransfer>
    </filter>

    <!-- Procedural texture: expensive, but it needs no image asset. -->
    <filter id="paper">
      <feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" result="noise"/>
      <feColorMatrix in="noise" type="saturate" values="0"/>
      <feComponentTransfer>
        <feFuncA type="linear" slope="0.12"/>
      </feComponentTransfer>
    </filter>

    <!-- A wavy displacement driven by turbulence -->
    <filter id="wobble">
      <feTurbulence type="turbulence" baseFrequency="0.02 0.06" numOctaves="2" result="warp"/>
      <feDisplacementMap in="SourceGraphic" in2="warp" scale="12" xChannelSelector="R" yChannelSelector="G"/>
    </filter>
  </defs>

  <g>
    <rect x="20"  y="30" width="60" height="60" fill="#0ea5e9" filter="url(#sepia)"/>
    <rect x="90"  y="30" width="60" height="60" fill="#10b981" filter="url(#greyscale)"/>
    <rect x="160" y="30" width="60" height="60" fill="#6d28d9" filter="url(#contrast)"/>
    <rect x="230" y="30" width="60" height="60" fill="#f59e0b" filter="url(#wobble)"/>
  </g>
</svg>
  • feColorMatrix type="saturate" values="0" is the shortest greyscale filter and the cheapest way to render a disabled state.
  • values for a matrix is a 4x5 grid: four rows for R, G, B and A, five columns for the source channels plus a constant. It is easier to copy a known matrix than to derive one.
  • feTurbulence is regenerated whenever the filter region changes, so a turbulence filter on a resizing element is a continuous cost. Apply it to a fixed-size element and scale the result.
  • feDisplacementMap reads the red and green channels of its second input by default to decide the x and y offsets. That is why a noise result is a natural driver.
  • Filters apply to the rendered result, so a filter on a group filters the whole group as one image — which is usually what you want and is also what makes it expensive.

Cost, alternatives and fallbacks

WantFilterBetter alternative
A drop shadowfeDropShadowfilter: drop-shadow() in CSS, or a box-shadow on an HTML element
A blurfeGaussianBlurfilter: blur() in CSS, or a pre-blurred image
GreyscalefeColorMatrixfilter: grayscale(1) in CSS
A texturefeTurbulenceA small tiled raster image
A gooey mergefeGaussianBlur plus feColorMatrixA rendered animation or a sprite sheet
A glitch effectfeDisplacementMapPre-rendered frames
Recolouring an iconfeColorMatrixcurrentColor, or a CSS mask
/* The CSS filter functions cover the common cases with a simpler syntax. */
.shadowed   { filter: drop-shadow(0 2px 4px rgba(15, 23, 42, 0.35)); }
.blurred    { filter: blur(4px); }
.dimmed     { filter: grayscale(1) brightness(0.9); }
.disabled   { filter: grayscale(1) opacity(0.5); }

/* Unlike an SVG filter, a CSS drop-shadow follows the alpha shape of the
   element, which is exactly what an icon with a transparent background wants.
   A box-shadow would draw a rectangle. */

/* Reduce the cost by making the element composited first. */
.animated-with-filter {
  will-change: filter, transform;
  /* Only worth it when the element genuinely animates: will-change on many
     elements is worse than none. */
}

/* A safe fallback for a filter the browser may skip. */
@supports not (filter: blur(4px)) {
  .blurred { opacity: 0.6; }
}
<!-- If a filter must not recompute on resize, freeze the element's size and
     scale the whole thing instead. -->
<svg width="120" height="120" viewBox="0 0 120 120" aria-hidden="true">
  <g filter="url(#paper)">
    <rect x="0" y="0" width="120" height="120" fill="#f8fafc"/>
  </g>
</svg>

<!-- and in CSS scale it, so the filter result is generated once:
     .paper-bg { transform: scale(2); transform-origin: 0 0; }  -->

The honest summary: filters are the most expensive feature in the SVG toolbox and the most commonly overused. Every one of them forces an offscreen render and a composite. If a design can be achieved with a CSS shadow, a pre-rendered asset or a solid colour, that will be faster and more predictable on every device.

FAQ

Why is my drop shadow cut off?
The filter region defaults to 10% of the element's bounding box on each side, which is often too small for the blur and offset. Set x="-20%" y="-20%" width="140%" height="140%" on the filter, and increase it further for a large blur radius.
Why did my animation become slow after adding a filter?
A filter makes the element composite into an offscreen buffer, and if the filtered content changes each frame the buffer is rebuilt each frame. Move the filter to a static parent, pre-render the effect, or use a CSS filter with will-change on a small element.

Transforms, clipping and masking Performance, rendering cost and canvas trade-offs

Last refreshed 2026-09-18.