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
sRGBthe weights apply to the gamma-encoded numbers, which strictly speaking gives luma, the value video systems use. InlinearRGBthe browser first decodes to linear light, so the same weights give true relative luminance. Linear luminance is small for most colors (mid-grey#808080is 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, …)orin srgbto 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.