unpalette
v0.0.1
Published
Color palette extraction, transformation, accessibility, quantization, and remapping for browsers and Node.js
Maintainers
Readme
unpalette
Color palette extraction, transformation, accessibility, quantization, and remapping for browsers and Node.js — zero runtime dependencies, one algorithm everywhere.
Features
- 🎨 Dominant color extraction — dominant-color clustering with largest-gap splitting, so a cut never lands inside a tight color cluster; colors come back sorted by pixel share with HEX output
- 🪣 Quantization — reduce any RGBA8 image to its dominant palette, alpha preserved per pixel
- 🖌 Remapping — apply a caller-provided palette (a fixed brand set, a terminal theme) with redmean nearest-color matching
- ⚡ One algorithm, every runtime — pure TypeScript with zero runtime dependencies; the browser and Node produce identical palettes from identical pixels
- 📦 Bounded work — large inputs are uniformly sampled down to a configurable pixel budget
- 🌈 Palette generation —
generatePalettederives complementary, analogous, triadic, split-complementary, and monochromatic palettes from one base color through classic color-wheel schemes - 🎭 Style variants —
extractPaletteVariantsreads one image into vivid, muted, dark, and light palettes by HSL attributes - 🧪 Color utilities — parse RGB, HSL, and OKLCH CSS colors, composite alpha, map out-of-gamut Oklch, interpolate in sRGB, linear sRGB, Oklab, or Oklch, generate scales and gradients, check WCAG contrast, and simulate color-vision deficiencies
Installation
# pnpm
pnpm add unpalette
# npm
npm install unpalette
# yarn
yarn add unpalette
# bun
bun add unpaletteQuick Start
import { applyPalette, extractPalette, quantize } from "unpalette";
const pixels = { data, width, height }; // RGBA8 — decode with unimage, canvas, or your own loader
// Dominant colors, sorted by pixel share.
const palette = extractPalette(pixels, { colors: 6 });
palette.colors.forEach(({ hex, ratio }) => console.log(hex, `${(ratio * 100).toFixed(1)}%`));
// Reduce the image onto its dominant palette (alpha preserved).
const reduced = quantize(pixels, { colors: 16 });
// Or remap onto a fixed brand palette.
const branded = applyPalette(pixels, [
{ r: 28, g: 28, b: 30 },
{ r: 255, g: 214, b: 10 },
{ r: 10, g: 132, b: 255 },
]);extractPalette skips fully transparent pixels by default (alphaThreshold: 1), and samples large inputs down to maxPixels (default 65536) before analysis. Nearest-color matching uses the redmean distance — noticeably better than plain RGB Euclidean while staying cheap enough for per-pixel lookups.
Color utilities and scales
The same package includes the color math behind palette tools:
import {
compositeColors,
contrastRatio,
formatColor,
generateColorScale,
generateGradient,
lighten,
mixColors,
inGamut,
readableTextColor,
rgbToOklch,
rotateHue,
simulateColorVision,
} from "unpalette";
const brand = "#3366cc";
const scale = generateColorScale(brand, { steps: 9, space: "oklch" });
const accent = rotateHue(brand, 180);
const blended = mixColors(brand, "#ffcc00", { amount: 0.25, space: "oklch" });
const gradient = generateGradient([brand, accent], { steps: 7, space: "oklch" });
const rendered = compositeColors("rgba(255, 0, 0, 0.5)", "#ffffff");
const displayable = toGamut({ l: 0.8, c: 0.35, h: 20 });
console.log(formatColor(blended, "hex"));
console.log(rgbToOklch(brand));
console.log(inGamut({ l: 0.8, c: 0.35, h: 20 }), displayable);
console.log(readableTextColor(brand));
console.log(contrastRatio(brand, "#ffffff"));
console.log(simulateColorVision(brand, "deuteranopia", { severity: 0.75 }));All functions are pure TypeScript and runtime-agnostic.
Generating palettes
Two paths produce palettes without decoding anything yourself:
import { extractPaletteVariants, generatePalette } from "unpalette";
// From one base color, through classic color-wheel schemes.
const complementary = generatePalette({ r: 255, g: 90, b: 0 }, "complementary");
const scale = generatePalette("#3366cc", "monochromatic");
// From one image: vivid, muted, dark, and light variants by HSL attributes.
const { vibrant, muted, dark, light } = extractPaletteVariants(pixels, {
variantColors: 4,
});Pairing with unimage
unpalette accepts raw RGBA8 pixels, so decoding lives wherever it belongs:
import { createImageManager } from "unimage";
import { sharpDriver } from "unimage/drivers/sharp";
import { extractPalette } from "unpalette";
const image = createImageManager({ driver: sharpDriver() });
const result = await image.transform(input, {
operations: [{ type: "resize", width: 256 }],
output: { format: "png", metadataPolicy: "strip" },
});
// Decode directly to RGBA8 pixels; no intermediate encoded image is needed.
const pixels = await image.decode(input);
const palette = extractPalette(pixels);
// Quantized pixels retain RGBA8 shape and can be passed back to unimage as raw input.
const reduced = quantize(pixels, { colors: 16 });
const rawImage = {
data: reduced.data,
width: reduced.width,
height: reduced.height,
layout: reduced.layout,
};Related Projects
- Graphics — the parent monorepo and ecosystem docs
- unimage — decode image bytes into the RGBA8 pixels that palette APIs consume
- Coolors — a reference for palette, tonal-scale, contrast, and color-vision workflows
