How it works

A gradient map in three filter primitives

Photo editors call it a gradient map: throw away the colors, keep the brightness, and paint every brightness level with a color from a ramp. An SVG filter does the same to any HTML, live.

1. Luminance: feColorMatrix

<feColorMatrix type="matrix"> multiplies every pixel's [R G B A 1] with a 4×5 matrix. Each row computes one output channel as a weighted sum of the inputs plus an offset. To get brightness, all three color rows use the same weights: the Rec. 709 coefficients 0.2126 R + 0.7152 G + 0.0722 B, which are also the ones sRGB and WCAG use. Green counts ten times as much as blue because our eyes are far more sensitive to it; a plain average would make blue skies look as bright as grass. The alpha row is 0 0 0 1 0, so transparency passes through untouched.

Contrast is folded into the same matrix: the weights are multiplied by the contrast and the offset column becomes p − p × contrast, which stretches the values around mid-grey p. In sRGB mode p is 0.5; in linearRGB mode it is 0.214, the linear-light value of the same grey (#808080), so the pivot doesn't jump when you switch. Results outside 0–1 are clamped, so high contrast pushes more of the image into the end colors.

2. The ramp: feComponentTransfer type="table"

<feComponentTransfer> sends each channel through its own function. With type="table" the function is a lookup table: n values in tableValues split the input range into n − 1 equal segments and interpolate linearly inside each. Since R, G and B now all hold the same luminance, the three tables together form a color ramp: the first value of each table is the shadow color's channel, the last one the highlight's, and an optional middle one the midtone's at 50% luminance. So the table values are simply the color stops, written channel by channel:

feFuncR
the red channels of the stops, shadows to highlights
feFuncG
the green channels
feFuncB
the blue channels

Two stops make a classic duotone, three make a gradient map. More stops would work the same way; type="discrete" instead of table would turn the smooth ramp into hard bands (see the halftone page for that).

3. Mix with the original: feComposite arithmetic

Below an Amount of 1, <feComposite operator="arithmetic"> blends the mapped result with the untouched SourceGraphic: result = k1·i1·i2 + k2·i1 + k3·i2 + k4, here with k2 = amount and k3 = 1 − amount, a plain linear mix. At exactly 1 the primitive is left out.

<svg width="0" height="0" aria-hidden="true">
  <filter id="duotone" x="0" y="0" width="100%" height="100%"
          color-interpolation-filters="sRGB">
    <!-- luminance (Rec. 709) into R, G and B; alpha unchanged -->
    <feColorMatrix type="matrix" values="
      0.2126 0.7152 0.0722 0 0
      0.2126 0.7152 0.0722 0 0
      0.2126 0.7152 0.0722 0 0
      0 0 0 1 0
    " />
    <feComponentTransfer>
      <feFuncR type="table" tableValues="0.1137 0.8392 1" />
      <feFuncG type="table" tableValues="0.0667 0.2 0.9098" />
      <feFuncB type="table" tableValues="0.2706 0.4235 0.6392" />
    </feComponentTransfer>
  </filter>
</svg>

The filter applies to HTML with a single CSS declaration, on an image, a video, a gradient or a whole section of a page:

.photo {
  filter: url(#duotone);
}

sRGB or linearRGB?

Filters compute in linearRGB by default, which is set with color-interpolation-filters. The choice changes two things here:

  • What gets measured. In sRGB the weights apply to the gamma-encoded numbers, which strictly speaking gives luma, the value video systems use. In linearRGB the browser first decodes to linear light, so the same weights give true relative luminance. Linear luminance is small for most colors (mid-grey #808080 is only 0.22), so the image slides towards the shadow end of the ramp.
  • How the ramp blends. The table interpolates in the filter's color space. Blending in linear light gives brighter, more saturated in-betweens, like mixing real light; sRGB blending looks like a CSS gradient. The ramp in the control panel uses linear-gradient(in srgb-linear, …) or in srgb to match.

The stop colors you pick are sRGB. In linearRGB mode the component converts them to linear values before writing tableValues; otherwise the browser would read the sRGB numbers as linear ones, and even pure shadow and highlight pixels would come out in the wrong color. sRGB is the default here because it matches what designers expect from Photoshop's gradient map.

Browser support and performance

filter: url(#id) pointing at an inline SVG filter works on HTML elements in all current engines, and these three primitives are among the oldest and best supported. Checked in Chromium and in Playwright's Firefox and WebKit builds, which rendered the same; real Safari is untested.

  • The filter runs per pixel over the whole element. On static content the result is cached, so it costs nothing after the first paint. Animated content (try Animate source) makes the browser rerun it on every frame; these primitives are cheap, but it still adds up on big, high-DPI areas.
  • Text inside the filtered element is recolored too, which can wreck its contrast. Put the filter on the image or background layer, not on the content that has to stay readable.
  • For a single static image, baking the effect into the file is cheaper still. The filter wins when the colors change with a theme or when the source is live HTML.

Back to the demo ↑