duotone
v0.1.0
Published
Luminance-preserving duotone utilities for Canvas, SVG, CSS, and RGBA pixel buffers.
Readme
Duotone
Turn image luminance into a two-colour treatment.
A duotone redraws an image with two colours. It does not tint the original: each source pixel is measured for how much light it represents, then replaced with the matching point on a ramp from a shadow colour to a highlight colour. The image keeps its contrast, edges, and composition while its original hues disappear.
duotone provides that mapping for CSS ramps, SVG filters, Canvas, and raw
RGBA buffers. It uses WCAG relative luminance in linear light, not a simple RGB
average, so green, red, and blue source pixels land where they should.
Install
npm i duotoneThe core export is ESM and has no DOM or Canvas requirement. The optional
duotone/img, duotone/svg, and duotone/css entry points cover Canvas
pixels, SVG filters, and reusable CSS values respectively.
How the mapping works
source RGB → linear-light relative luminance → optional tone shaping
→ generated 256-colour ramp → replacement RGBThe 256-colour ramp is generated from your two chosen hex colours at runtime. There is no pasted list of colour stops to maintain: the same palette, midpoint, contrast, and interpolation settings drive the Canvas, SVG, and raw pixel paths.
Canvas pixels
Use duotonePixels when you already have ImageData. It changes the supplied
RGBA buffer in place and preserves every alpha channel.
import { createDuotone, duotonePixels } from "duotone";
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("A 2D canvas context is required");
const image = context.getImageData(0, 0, canvas.width, canvas.height);
const palette = createDuotone("#190c2e", "#ff9a3c");
duotonePixels(image.data, palette, {
contrast: 0.15,
space: "oklch",
});
context.putImageData(image, 0, 0);The function also works with an RGBA Uint8ClampedArray from an image decoder
in Node. Its only requirement is four channels per pixel.
Browser image helper
For a loaded image, video frame, canvas, or ImageBitmap, use the separate
Canvas convenience entry point. It returns a new canvas, leaving the source
untouched.
import apply from "duotone/img";
const result = apply(image, {
shadow: "#190c2e",
highlight: "#ff9a3c",
midpoint: 0.45,
contrast: 0.15,
});
document.querySelector("main")?.append(result);The source must be loaded and readable by Canvas. For images from another origin, configure CORS before drawing them or the browser will taint the canvas and reject pixel access.
CSS and SVG
Use the CSS helper to get a ready-to-assign gradient and retain its calculated ramp for other surfaces later, such as text fill or design tokens:
import duotoneCss from "duotone/css";
const styles = duotoneCss({
shadow: "#17112b",
highlight: "#ffbe73",
angle: 135,
});
card.style.background = styles.gradient;
// styles.ramp is available for text fill or tokens.For a live DOM element, install an SVG filter once and use the returned CSS value on any element you want to treat:
import mountDuotone from "duotone/svg";
const filter = mountDuotone(document.body, {
id: "poster-duotone",
shadow: "#190c2e",
highlight: "#ff9a3c",
contrast: 0.15,
});
document.querySelector(".poster")?.style.setProperty("filter", filter);Tuning a treatment
const palette = createDuotone("#17112b", "#ffbe73");
const warm = duotoneRamp(palette, 9, {
midpoint: 0.42,
contrast: 0.2,
space: "oklch",
});midpointcontrols which source luminance reaches the middle of the ramp. Lower it to open shadow detail; raise it to make the image darker.contrastis an S-curve from-1to1. Positive values separate tones; negative values flatten them. Both ends of the ramp remain pinned.space: "srgb"is the default and matches a CSS gradient. Usespace: "oklch"when distant hues would otherwise make the middle of a ramp feel dull or grey.
Good source material has a real spread of shadow and highlight detail. A duotone cannot recover tonal structure from a flat, low-contrast image, and it should not be used when the original colour is information (charts, maps, or product colours).
API
| Export | Purpose |
| --- | --- |
| createDuotone(shadow, highlight) | Creates a DuotonePalette from two non-empty CSS colour strings. |
| duotonePixels(pixels, palette, options?) | Maps an RGBA Uint8ClampedArray in place; alpha is retained. |
| duotoneLut(palette, options?) | Generates a 256-entry RGB lookup table for a pixel pipeline. |
| duotoneSvgFilter(palette, id?, options?) | Returns an SVG filter that uses the same luminance mapping. |
| default from "duotone/svg" | Installs an SVG filter once and returns its CSS url(#id) value. |
| default from "duotone/css" | Returns a CSS gradient plus its generated ramp for reuse. |
| duotoneGradient(palette, angle?) | Formats a CSS linear-gradient. |
| duotoneRamp(palette, steps?, options?) | Returns evenly spaced hex stops across the ramp. |
| mapLuminance(palette, position, options?) | Returns the hex colour at a clamped 0–1 ramp position. |
| shapeLuminance(luminance, options?) | Applies the midpoint and contrast curve before mapping. |
| relativeLuminance(rgb) | Returns WCAG relative luminance from 0 (black) to 1 (white). |
| parseHex(hex) / formatHex(rgb) | Parses and formats #rgb / #rrggbb values. |
duotonePixels, duotoneLut, mapLuminance, duotoneRamp, and
duotoneSvgFilter need hex palette colours because they interpolate numeric
RGB values. createDuotone and duotoneGradient can still hold any valid CSS
colour string.
Development
npm run build --workspace=duotone
npm test --workspace=duotoneMIT
