Iurii RoguliaIurii Rogulia
AboutAI EngineeringServicesProjectsAI SkillsStackTestimonialsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a numberThink of a birthday
Hourly ratesChecklistsPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

  1. Home
  2. /
  3. Blog
  4. /
  5. SVG Product Color Preview: feColorMatrix and Why I Replaced It

Iurii Tests: SVG Product Color Preview: feColorMatrix and Why I Replaced It

One SVG filter recolors a transparent product photo to any color. I built it, replaced it the next day, and measured both versions for this article.

November 4, 2026· 9 min read

An SVG product color preview from one transparent photo works, but the result depends on the photo’s tonal range. Measured, with what replaced it.

Stack

Next.jsReactTypeScript

Topics

E-commerceSVGCSSPerformanceUX
SVG Product Color Preview: feColorMatrix and Why I Replaced It

Short answer: yes, one SVG filter can recolor a transparent-background product photo to any color. A feColorMatrix that desaturates the image, followed by a three-stop feComponentTransfer table, is about twenty lines. It works. But the result depends on the tonal range of the photo you feed it, and on the masters I tested that dependency pushed the colors darker or lighter than requested.

I found this while building a color preview for pi-pi.ee, an online shop I built. The shop stocks black, grey and white products; other colors are made to order, and a buyer has to imagine them. I wanted a live preview. I built the first version with an SVG filter and replaced it the next day. I measured both versions later, for this article, so you can decide whether the simple one is enough for your photos. The shop-side context (color names, the order request) is in the case study.

How the filter works

The idea is a duotone: take the photo’s brightness, then map it onto a ramp that runs from a dark version of the target color, through the target color, to a lighter version. The photo’s own shading survives because brightness is the input.

Here is the shape of the filter, trimmed from the first version. It is applied to the photo with CSS filter: url(#tint):

const SHADOW_MUL = 0.28;
const HIGHLIGHT_MIX = 0.68;
 
// channel is one of the target color's R, G, B values, 0..1
const ramp = (c: number) => [c * SHADOW_MUL, c, c + (1 - c) * HIGHLIGHT_MIX].join(" ");
 
<filter id="tint" colorInterpolationFilters="sRGB">
  <feColorMatrix type="saturate" values="0" />
  <feComponentTransfer>
    <feFuncR type="table" tableValues={ramp(r)} />
    <feFuncG type="table" tableValues={ramp(g)} />
    <feFuncB type="table" tableValues={ramp(b)} />
  </feComponentTransfer>
</filter>;

saturate with value 0 turns the photo grey, so R, G and B all equal its luminance. Each feFunc with type="table" then maps that value through a piecewise-linear lookup. With three values, the input range splits into two halves:

StopValueInput luminanceMeaning
1c × 0.280shadow: 28% of the target channel
2c0.5base: the requested color itself
3c + (1 − c) × 0.681highlight: 68% of the way to white

Luminance from 0 to 0.5 interpolates from shadow to base. From 0.5 to 1 it interpolates from base to highlight. Because the table is applied per channel, the same lookup tints all three channels at once.

colorInterpolationFilters="sRGB" matters. The default for filter primitives is linearRGB, which would run the table on linear values and shift the midtones; setting sRGB keeps the arithmetic on the values you see in the image.

Notice what the table assumes: that the photo’s brightness spreads across the whole 0 to 1 range, with the midtone near 0.5. Nothing in the filter checks this.

Where it breaks

The pi-pi.ee photos come as transparent-background masters. The first version used the black master. The next day I replaced it with a version built on a white master. My commit message gave the reason: the black master has no highlight and shadow range, so white and saturated custom colors came out flat and muddy.

The commit message is a qualitative judgement, so I wanted numbers. I applied the same filter in headless Chrome to the masters of one product. I measured luminance of the opaque pixels (alpha 200 or more in this harness) as 0.213R + 0.715G + 0.072B on sRGB-encoded 0 to 255 values, at the 2nd, 50th and 98th percentile:

Imagep2p50p98
Black master, source1138106
Black master, filtered to green #2f7d4f355192
Black master, filtered to brown #6b4a2f273869
White master, source184227246
White master, filtered to green150185200
White master, filtered to brown132172190

For reference, the requested colors have a luminance of about 105 (green) and 79 (brown), by the same formula.

The mechanism follows from the table. At the 98th percentile the black master sits at 106 out of 255, about 0.42, below the base stop at 0.5. So at least 98% of its opaque pixels use only the shadow-to-base half of the ramp, and the filtered result is darker than the requested color at that percentile: green 92 against 105, brown 69 against 79. To my eye the hues come out dark and close to each other.

The white master has the opposite problem. Its 2nd percentile is 184, about 0.72, so at least 98% of its pixels sit in the base-to-highlight half. The filtered result is lighter than requested even at the 2nd percentile: green 150 against 105, brown 132 against 79. To my eye it looks pastel.

Two limits. This is one product’s masters in one browser (headless Chrome), so treat it as an illustration of the mechanism, not a benchmark. And the visual descriptions above are my own reading.

The cause is not a bug in the filter. The three-stop table encodes a fixed idea of where ‘the color’ sits in the brightness range, and each master puts its pixels somewhere else. I did not test whether per-image gain and offset values inside the SVG could fix it. It may well be possible; I went another way.

What replaced it

The current version in the repo is a canvas recolor. It differs from the first version in two ways, and both landed in the same commit: it works from the white master, and it normalizes the photo’s luminance before applying the color. I did not test the two changes separately.

  1. Load the master once and compute luminance per pixel (0.213R + 0.715G + 0.072B).
  2. Build a histogram of the pixels with alpha above 150, then stretch the range from the 2nd to the 99th percentile onto 0 to 1. Call the result n.
  3. On each color change, for every pixel: mod = 0.5 + 0.5·n scales the target color from half brightness in the darkest pixels to full, spec = clamp((n − 0.85) / 0.15) × 0.5 mixes up to 50% white into the brightest pixels, and the output is diffuse + (1 − diffuse) × spec, where diffuse is the target color channel scaled by mod, with the original alpha kept.

I ran these exact formulas, with the 420 px preview cap, on the same two masters in headless Chrome. Same luminance measure as before, with the stretch range (lo..hi) in the first column:

Imagep2p50p98
Black master, canvas, green (stretch 11..121)5365121
Black master, canvas, brown404999
White master, canvas, green (stretch 183..248)5388164
White master, canvas, brown4066148

Requested luminance is still 105 for green and 79 for brown.

What normalization changes: the floor. The darkest pixels map to mod = 0.5 whichever master they come from, so p2 is identical for both (53 for green, 40 for brown), and each master now uses the full 0 to 1 range. What it does not change: the distribution. The median is 65 against 88 for green, and p98 is 121 against 164, because the white master has more of its pixels in the bright zone. The result is still not independent of the master. That is also why I read the white master as a real part of the change, not an incidental detail: it gives a spread of highlights to work with. These numbers cannot tell me how much each change contributed.

To my eye the result reads more like colored plastic than a tinted greyscale. That is a judgement, not a measurement.

Canvas has costs the filter did not. Every color change walks all the pixels in JavaScript. I cap the preview at 420 px on the long side to keep that bounded, and I have not measured the per-change time. Reading pixels also needs the images to come from the same origin, or from a server that sends CORS headers with img.crossOrigin = "anonymous" set. And it is more code to maintain than one <filter>. For another canvas pipeline where drawing the same output on server and client mattered, see the isomorphic canvas post.

When the SVG filter is still enough

My judgement, not a universal rule: the three-stop filter is enough when you control the source photos and can make them agree. If every master is exposed so that its brightness spans roughly the full range with the midtone near 0.5, the table does what it says. It is also enough for a flat icon or illustration where the shading is part of the design, and when you want zero JavaScript per color change.

It is a poor choice when your masters differ in exposure, when you need the requested color to look like that color in the image, or when you cannot control the source. The test is cheap: apply the filter to your own images and compare the percentiles of the output against the color you asked for, as I did above.

Related service

E-commerce

Need a live preview of product variants in your online shop – colors, finishes, options – without shooting every combination? This is the kind of front-end work I do for e-commerce.

More about this service →

Takeaways

  • A table-based feComponentTransfer has no normalization. The photo’s luminance range decides which part of the ramp you use.
  • Measure the output against the requested color before judging by eye. Percentiles show where the dark and bright ends of the output land; a glance at the preview does not.
  • Normalizing the input (percentile stretch) pins the floor and uses the full range, but the median and bright end still follow the master. The cost is per-pixel work on each color change.
  • Keep the preview honest. It shows an approximation of a made-to-order color, and the buyer still confirms it in the request.

In the shop, the picked color’s name pre-fills the special-order request, so what the buyer saw is what lands in the message. If you are building a similar configurator, the state side is covered in the React configurator article.

Iurii RoguliaAvailable

E-commerce

Want shoppers to see a product in the variant they are about to ask for, without shooting every variant? I build live previews like this for online shops.

More about this service

Relevant client work

View all projects
pi-pi.ee – Live Custom-Colour Preview
pi-pi.ee – Live Custom-Colour Preview
July 28, 2026
pi-pi.ee – Live Custom-Colour Preview

Recolour a product photo to any colour in the browser. A canvas pass tints one white master per photo, so made-to-order colours need no extra photos.

HTPBE.TECH – PDF Verification Workflow Animation
HTPBE.TECH – PDF Verification Workflow Animation
March 4, 2026
HTPBE.TECH – PDF Verification Workflow Animation

Looping SVG animation illustrating a PDF verification pipeline – document stack, cloud processing, and green/red folder sorting.

pi-pi.ee – B2B E-commerce for Waterless Urinal Systems
pi-pi.ee – B2B E-commerce for Waterless Urinal Systems
January 17, 2026
pi-pi.ee – B2B E-commerce for Waterless Urinal Systems

i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.

What clients say

“

I’d built most of our MVP with Cursor and it looked finished – it compiled, the tests were green, the demo worked. It just wouldn’t survive real users.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time.

Bram de Vries 🇳🇱

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs
“

I had a validated idea and a deadline tied to an accelerator demo day, but no product.

Aino Virtanen 🇫🇮

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS

Related articles

B2B Quote-to-Order Flow in Next.js: A State Machine That Doesn’t Drift
August 7, 2026· 10 min
B2B Quote-to-Order Flow in Next.js: A State Machine That Doesn’t Drift

B2B quote-to-order flow in Next.js: RFQ → quote → order state machine, per-deal line items, guarded transitions, and quote conversion without data drift.

Stack

Next.jsReactTypeScript

Databases

PostgreSQL
Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access
July 24, 2026· 9 min
Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access

Gated B2B pricing in Next.js: per-account price lists and server-side access control so prices never leak to crawlers.

Stack

Next.jsReactTypeScript

Databases

PostgreSQL
Building a React Product Configurator with Zustand and Motion
April 21, 2026· 12 min
Building a React Product Configurator with Zustand and Motion

Build a React product configurator with Zustand, DaisyUI, Motion animations and a live SVG preview – architecture, pricing logic, and performance patterns.

Stack

Next.jsReactTypeScript
React Email + Resend Tutorial: Transactional Emails with PDF Invoices
April 5, 2026· 13 min
React Email + Resend Tutorial: Transactional Emails with PDF Invoices

Production transactional emails with React Email and Resend: PDF invoices via react-pdf, Vercel Blob temporary storage, and BullMQ Stripe webhooks.

Stack

Next.jsTypeScriptReact
SVG Keyframe Animation in Pure CSS (No Library)
March 5, 2026· 7 min
SVG Keyframe Animation in Pure CSS (No Library)

CSS SVG animation with pure @keyframes: sync multiple tracks, fix the SVG transform override trap, and build smooth loops without GSAP or Framer Motion.

Stack

TypeScriptReact