npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

TypeScript npm

duotone.dev

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 duotone

The 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 RGB

The 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",
});
  • midpoint controls which source luminance reaches the middle of the ramp. Lower it to open shadow detail; raise it to make the image darker.
  • contrast is an S-curve from -1 to 1. 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. Use space: "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=duotone

MIT