Looping SVG animation illustrating a PDF verification pipeline — document stack, cloud processing, and green/red folder sorting — built with pure CSS
Recolour a product photo to any colour in the browser, instantly — an SVG duotone filter tints transparent master images on the fly, so a shop can offer made-to-order colours without pre-rendering a single variant.
Key Results

Pi-pi.ee stocks its products in three colours — black, grey, white — and photographs each one. But customers kept asking for colours that aren't on the shelf: a green to blend into a garden, an anthracite to match a façade, a terracotta for a courtyard. Every one of those is a made-to-order paint job, perfectly doable, but invisible on the website. The buyer had to imagine it.
The obvious fix — photograph the product in every offered colour — doesn't scale. Ten colours across several product shots is dozens of new image files to shoot, edit, name, store, and re-do the moment the catalogue changes. And it still only covers a fixed list. The real requirement was different: let the visitor see any offered colour on the actual product, right on the page, without the shop producing a single extra photo.
The recolouring happens entirely in the browser, on the photo that's already loaded. Each product photo ships with a transparent-background master. A single SVG duotone filter desaturates that master to pure luminance and re-maps it onto a colour ramp built from the chosen colour — shadows to a darkened version, midtones to the colour itself, highlights to a lightened version. The product's own lighting is preserved, so a recoloured shot still reads as a photograph rather than a flat silhouette.
The visitor picks from a set of named swatches — no raw hex or RGB input, just "olive", "terracotta", "anthracite" — and the preview updates on the same frame. Because a custom colour is a special order rather than a stock SKU, the chosen colour name is reported back up to the product form and pre-filled into the order request, so what the buyer saw is exactly what the shop receives. Swatch labels run through the same next-intl dictionary as the rest of the catalogue, so the colour names are localised alongside everything else.
This is the exact recolour that runs on the product page — the same transform, live in your browser: the product's white master is painted with a diffuse tint plus a white specular highlight. Pick a colour and pull the sliders: saturation, shadow depth and highlight are the transform's real knobs.
| Aspect | Outcome |
|---|---|
| Recolour latency | Instant — CSS filter, zero network requests |
| Image assets | One transparent master per photo covers every colour |
| Colour range | Any target colour; the UI exposes a curated, localised set |
| Shading fidelity | Original shadows and highlights preserved via a luminance ramp |
| Order accuracy | Chosen colour name pre-fills the special-order request |
For the technically curious, here's how the live preview is built.
The core is an SVG filter that turns a product photo into pure brightness, then paints that brightness with the target colour. feColorMatrix with saturate 0 collapses the image to luminance; feComponentTransfer then rewrites each channel through a three-stop table — shadow, base, highlight — derived from the target colour. The shadow stop keeps folds and contours dark; the highlight stop keeps sheen; the base stop is the colour itself.
const SHADOW_MUL = 0.28;
const HIGHLIGHT_MIX = 0.68;
function hexToRgb(hex: string): [number, number, number] {
const v = hex.replace("#", "");
return [
parseInt(v.slice(0, 2), 16) / 255,
parseInt(v.slice(2, 4), 16) / 255,
parseInt(v.slice(4, 6), 16) / 255,
];
}
// 3-stop luminance ramp per channel: shadow -> base -> highlight.
function ramp(channel: number): string {
const shadow = channel * SHADOW_MUL;
const highlight = channel + (1 - channel) * HIGHLIGHT_MIX;
return `${shadow.toFixed(3)} ${channel.toFixed(3)} ${highlight.toFixed(3)}`;
}The filter itself is a few static nodes; only the tableValues change when the colour changes, and React re-renders just those three strings:
const rgb = hexToRgb(selectedHex);
<filter id={filterId} colorInterpolationFilters="sRGB">
<feColorMatrix type="saturate" values="0" />
<feComponentTransfer>
<feFuncR type="table" tableValues={ramp(rgb[0])} />
<feFuncG type="table" tableValues={ramp(rgb[1])} />
<feFuncB type="table" tableValues={ramp(rgb[2])} />
</feComponentTransfer>
</filter>;Applying it is a single CSS property on the image — the recolour rides the GPU compositor, not a redraw:
<img src={src} style={{ filter: `url(#${filterId})` }} loading="lazy" />The catalogue only stores the stock photo paths (12-black.webp). The picker derives the transparent master path from those by convention, and quietly drops any image that has no master (diagrams, mesh close-ups), so the preview only ever shows the shots that can actually be tinted.
function transparentMasters(images: string[]): string[] {
const out: string[] = [];
for (const src of images) {
const m = src.match(/^(.*\/)(\d+)-(?:black|grey|white)\.(?:webp|png)$/);
if (m !== null) out.push(`${m[1]}${m[2]}-transparent.png`);
}
return out.slice(0, 3);
}A custom colour isn't a stock SKU, so it can't just be added to the cart — it becomes a special-order request. The picker reports the chosen colour name (already localised) up to the product form, which pre-fills it into the enquiry. There's no second place for the buyer to re-state the colour, and no gap between the preview and the order:
const [customColorName, setCustomColorName] = useState<string | null>(null);
<CustomColorPicker images={images} onChange={setCustomColorName} />;
// ...the form's colour field is driven by customColorName
AvailableNeed something similar?
I build custom solutions — from APIs to full products. Let's talk about your project.
Looping SVG animation illustrating a PDF verification pipeline — document stack, cloud processing, and green/red folder sorting — built with pure CSS
i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.
Gated B2B pricing in Next.js: per-account price lists, server-side access control so prices never leak to crawlers, and a deal-record data model where every
How to build a React product configurator with Zustand state, DaisyUI components, Motion animations, and live SVG preview — architecture, pricing logic, and