Back to Color Thief

Color Thief v3

V3.md

3.3.123.1 KB
Original Source

Color Thief v3

Overview

v3 is a ground-up rewrite of Color Thief in TypeScript. The library moves from a split browser/Node codebase with divergent APIs to a single unified async API that auto-detects the runtime environment. Colors are no longer returned as raw [r, g, b] arrays — they come back as rich Color objects with built-in format conversion, accessibility metadata, and perceptual color space support.


What changed

Language and build system

  • TypeScript source. All code in src/ is now .ts. Strict mode is enabled. Published .d.ts declarations are generated from the source (no more hand-maintained type stubs).
  • tsup replaces microbundle. The build produces four artifact sets:
    • dist/browser/ — ESM (.js) and CJS (.cjs) for browsers
    • dist/node/ — ESM (.js) and CJS (.cjs) for Node.js
    • dist/umd/ — Minified IIFE exposing a ColorThief global
    • dist/types/.d.ts and .d.cts declarations
  • Package "type": "module". The package is now ESM-first. CJS consumers use the .cjs entry points via the conditional exports map.
  • Conditional exports map. Bundlers and runtimes that support the exports field in package.json will automatically resolve to the correct browser or Node build.

API shape

Concernv2v3
Browser importnew ColorThief() class, methods on prototypeNamed function imports: getColor(), getPalette(), getSwatches(), etc.
Node importrequire('colorthief') returns { getColor, getPalette }Same named imports as browser: import { getColor } from 'colorthief'
Browser return typeSynchronous [r, g, b] tuple or nullPromise<Color | null>
Node return typePromise<[r, g, b] | null>Promise<Color | null> (same as browser)
OptionsPositional args (img, colorCount, quality) or options objectSingle options object only: { colorCount, quality, ... }
Legacy methodsgetColorFromUrl(), getColorAsync(), getImageData()Removed. Use getColor() with a loaded image.

The browser API defaults to async, but synchronous functions are available for browser-only use cases (see below).

Color objects

v2 returned raw [r, g, b] arrays. v3 returns Color objects:

ts
const color = await getColor(img);

color.rgb()       // { r: 232, g: 67, b: 147 }
color.hex()       // '#e84393'
color.hsl()       // { h: 330, s: 75, l: 59 }
color.oklch()     // { l: 0.63, c: 0.19, h: 352 }
color.array()     // [232, 67, 147]  ← v2 format, for back-compat
color.toString()  // '#e84393'  — works in template literals and string contexts
color.textColor   // '#000000'  — readable text color for this background
color.isDark      // false
color.isLight     // true
color.population  // 1
color.contrast    // { white: 3.42, black: 6.14, foreground: Color(0,0,0) }

// Colors work directly in string contexts:
element.style.backgroundColor = color;          // '#e84393'
element.style.color = color.textColor;          // '#000000'
console.log(`Dominant color: ${color}`);         // 'Dominant color: #e84393'
  • toString() returns the hex string, so Colors work in template literals, CSS assignment, and console.log without calling .hex().
  • textColor returns '#ffffff' or '#000000' — the readable foreground color for this background. A plain string, ready for CSS.
  • HSL and OKLCH are computed lazily and cached on first access.
  • isDark / isLight use WCAG relative luminance with a 0.179 threshold.
  • contrast provides WCAG contrast ratios against white and black, plus a suggested foreground Color (white or black) for readable text overlays.
  • population exposes the relative pixel count from the quantizer (always 1 for the default MMCQ quantizer; meaningful when using the WASM quantizer or a custom one).

Synchronous browser API

For browser-only use cases where you don't need Worker offloading, AbortSignal, or Node.js support, v3 provides synchronous variants:

ts
import { getColorSync, getPaletteSync, getSwatchesSync } from 'colorthief';

const color = getColorSync(imgElement);
element.style.backgroundColor = color.hex();

const palette = getPaletteSync(imgElement, { colorCount: 5 });
const swatches = getSwatchesSync(imgElement);

These accept BrowserSource only (HTMLImageElement, HTMLCanvasElement, ImageData, ImageBitmap) and take a SyncExtractionOptions object (same as ExtractionOptions minus worker, signal, and loader). They run entirely on the main thread with no Promise overhead.

Use the sync API when:

  • You want the simplest possible usage (no await, no async context)
  • You're in a synchronous callback or render function
  • The image is already loaded and you just want a result immediately

Use the async API when:

  • You need Worker offloading, AbortSignal cancellation, or progressive extraction
  • Your source is a Node.js file path or Buffer
  • You want to use a custom loader

Per-call quantizer and loader

In addition to the global configure(), you can pass quantizer and loader per-call:

ts
import { getPalette } from 'colorthief';
import { WasmQuantizer } from 'colorthief/internals';

const q = new WasmQuantizer();
await q.init();

// Use WASM quantizer for just this call:
const palette = await getPalette(img, { quantizer: q, colorCount: 10 });

Per-call options take priority over configure() globals. This is useful when you want the WASM quantizer for one expensive extraction but the default MMCQ for everything else, or when running different loaders for different source types.

New features

Semantic swatches (getSwatches())

ts
const swatches = await getSwatches(img);
swatches.Vibrant?.color.hex()         // '#e84393'
swatches.DarkMuted?.titleTextColor    // Color for readable title text

Returns a SwatchMap with six roles: Vibrant, Muted, DarkVibrant, DarkMuted, LightVibrant, LightMuted. Classification uses OKLCH lightness and chroma bands with weighted distance scoring (lightness 6x, chroma 3x, population 1x). Each swatch includes titleTextColor and bodyTextColor recommendations. Roles that can't be matched are null.

OKLCH quantization

ts
const palette = await getPalette(img, { colorSpace: 'oklch' });

When colorSpace is set to 'oklch', the pixel array is converted to OKLCH (scaled to 0–255 for MMCQ compatibility) before quantization, then converted back to RGB. This produces more perceptually uniform palettes — colors that "feel" evenly spaced to the human eye rather than being evenly spaced in sRGB.

Progressive extraction (getPaletteProgressive())

ts
for await (const { palette, progress, done } of getPaletteProgressive(img)) {
    renderPreview(palette, progress); // 0.06 → 0.25 → 1.0
}

Runs three passes with increasing quality (16x skip, 4x skip, full quality). Each pass yields a { palette, progress, done } result. A setTimeout(0) between passes yields to the main thread so the UI stays responsive during extraction of large images.

Web Worker offloading

ts
const palette = await getPalette(img, { worker: true });

When worker: true is passed, the quantization step runs in a Web Worker using an inline Blob URL (no separate worker file to serve). If the environment doesn't support Workers, it silently falls back to the main thread. The worker manager handles message ID tracking, promise resolution, and cleanup.

AbortSignal cancellation

ts
const controller = new AbortController();
const palette = await getPalette(img, { signal: controller.signal });

// Cancel from anywhere:
controller.abort();

All async API functions accept an AbortSignal. The signal is checked before pixel loading, between progressive passes, and propagated into worker communication. An already-aborted signal rejects immediately.

Pluggable architecture (configure() and per-call options)

ts
import { configure, getPalette } from 'colorthief';
import { WasmQuantizer, createNodeLoader } from 'colorthief/internals';

// Global: swap the quantizer for all calls
const q = new WasmQuantizer();
await q.init();
configure({ quantizer: q });

// Global: swap the pixel loader for all calls
configure({ loader: createNodeLoader({ decoder: myCustomDecoder }) });

// Per-call: override for just this extraction
const palette = await getPalette(img, { quantizer: someOtherQuantizer });

The PixelLoader and Quantizer interfaces are public contracts. You can replace the default MMCQ quantizer or the default sharp/canvas pixel loader with your own implementation. Per-call quantizer and loader options take priority over configure() globals.

WASM quantizer backend

A Rust implementation of the full MMCQ algorithm lives in src/wasm/. It implements:

  • 5-bit quantized 3D color histogram (32,768 bins)
  • VBox data structure with count/volume tracking
  • Median-cut splitting along the widest dimension
  • Two-phase iteration (75% by population, remainder by population x volume)

The WasmQuantizer TypeScript adapter flattens pixel arrays to Uint8Array, calls into WASM, and parses the 7-byte-per-color result format (3 bytes RGB + 4 bytes little-endian population). The WASM module must be compiled separately with wasm-pack build --target web.

Dependency changes

v2v3
sharpDirect dependency (always installed)Optional peer dependency (only needed for Node.js)
ndarray-pixelsDirect dependencyRemoved entirely — sharp's .raw().toBuffer() is used directly
@lokesh.dhakar/quantizeDirect dependencyDirect dependency (unchanged)
typescriptNot presentdevDependency
tsupNot presentdevDependency (replaces microbundle)
microbundledevDependencyRemoved

Import paths

The package has two entry points to keep the common-case autocomplete clean:

ts
// Main — what 95% of users need
import { getColor, getPalette, getSwatches, createColor } from 'colorthief';

// Sync browser variants
import { getColorSync, getPaletteSync, getSwatchesSync } from 'colorthief';

// Internals — loaders, quantizers, color-space math, worker manager
import { MmcqQuantizer, WasmQuantizer, rgbToOklch } from 'colorthief/internals';

colorthief exports: getColor, getPalette, getSwatches, getPaletteProgressive, configure, getColorSync, getPaletteSync, getSwatchesSync, createColor, and all public types (including SyncExtractionOptions).

colorthief/internals exports: MmcqQuantizer, WasmQuantizer, BrowserPixelLoader, NodePixelLoader, createNodeLoader, classifySwatches, color-space conversion functions, worker manager functions, and low-level pipeline functions.

File structure

src/
  types.ts              All interfaces and type aliases
  color.ts              Color object implementation
  color-space.ts        RGB ↔ OKLCH conversion functions
  pipeline.ts           Core extraction engine (replaces core.js)
  api.ts                Public async API functions
  sync.ts               Public sync browser-only API functions
  swatches.ts           Semantic swatch classification
  progressive.ts        Multi-pass progressive extraction
  index.ts              Main entry point (re-exports)
  internals.ts          Power-user entry point (re-exports)
  umd.ts                UMD/IIFE entry point
  declarations.d.ts     Ambient type declarations for untyped deps
  loaders/
    browser.ts          Canvas-based pixel extraction
    node.ts             Sharp-based pixel extraction with pluggable decoder
  quantizers/
    mmcq.ts             MMCQ adapter (static import of @lokesh.dhakar/quantize)
    wasm.ts             WASM quantizer adapter
  worker/
    worker-script.ts    Inline worker script source
    manager.ts          Worker lifecycle and message management

  # Old v2 files (kept for reference during migration):
  color-thief.js        Browser v2 source
  color-thief-node.js   Node v2 source
  core.js               Shared v2 utilities
  color-thief.d.ts      Browser v2 type stubs
  color-thief-node.d.ts Node v2 type stubs

  # WASM backend (compile separately):
  wasm/
    Cargo.toml
    src/lib.rs

Test changes

The test suite was rewritten:

v2v3
Node test count22 tests50 tests
Test formatCommonJS (require)ESM (import)
AssertionsCheck [r,g,b] arraysCheck Color objects (.rgb(), .hex(), .isDark, etc.)

New test coverage areas:

  • Color object methods (rgb, hex, hsl, oklch, array, isDark, isLight, contrast, population)
  • RGB → OKLCH → RGB round-trip accuracy (9 reference colors, ±1 tolerance)
  • getSwatches() structure and role assignment
  • OKLCH color space quantization option
  • AbortController cancellation
  • Progressive extraction (3 passes, progress values, final palette)

Benefits for consumers

Eliminates boilerplate color conversion code

v2 returned raw [r, g, b] arrays, so every project using Color Thief needed its own RGB-to-hex, RGB-to-HSL, or "is this color dark?" utility. v3 builds all of that into the Color object. Common patterns that previously required 5–20 lines of helper code become a single property access:

ts
// v2: need your own conversion
const [r, g, b] = colorThief.getColor(img);
const hex = '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
const luminance = 0.2126 * (r/255) + 0.7152 * (g/255) + 0.0722 * (b/255);
const textColor = luminance < 0.5 ? '#fff' : '#000';

// v3: built in
const color = await getColor(img);
const hex = color.hex();
const textColor = color.contrast.foreground.hex();

First-class TypeScript support

v2 shipped hand-maintained .d.ts files that were separate from the source and could drift. v3 generates declarations directly from the TypeScript source, so types are always accurate. All public interfaces (Color, ExtractionOptions, SwatchMap, Quantizer, etc.) are exported and documented with JSDoc.

Consistent API across platforms

v2 had fundamentally different APIs on browser vs Node: the browser version was a class with synchronous methods, and the Node version was a module with async functions. You couldn't write a utility that worked in both environments without platform-specific code. v3 exports the same functions with the same signatures and return types on both platforms. Platform detection happens internally.

Smaller Node.js install

sharp moves from a required dependency to an optional peer dependency. For browser-only projects, npm install colorthief no longer downloads sharp and its native binaries (which can be 30+ MB depending on platform). Node.js projects that need image decoding install sharp separately.

ndarray-pixels is removed entirely (saved ~50 KB of dependencies). v3 uses sharp's .raw().toBuffer() directly.

Accessibility built in

Every Color object has WCAG contrast ratios and a suggested foreground color. The getSwatches() function returns text color recommendations per swatch. This means you can build accessible UIs (e.g. colored cards with readable text) directly from extraction results without a separate contrast-checking library.

Perceptually uniform palettes

The OKLCH quantization option ({ colorSpace: 'oklch' }) produces palettes that look more evenly distributed to the human eye. sRGB quantization (the default, same as v2) can over-represent greens and under-represent blues because sRGB is not perceptually uniform. OKLCH quantization fixes this.

UI responsiveness for large images

Progressive extraction lets you show a rough palette instantly (6% of pixels sampled in ~1ms) and refine it as the full extraction completes. This matters for large images where full extraction can take 50–200ms. The setTimeout(0) yield between passes ensures the browser doesn't freeze.

Web Worker offloading moves the quantization math entirely off the main thread, eliminating jank for any image size.

Cancellable extraction

Long-running extractions can be cancelled via AbortSignal. This is important for UIs where the user navigates away before extraction finishes — previously there was no way to abandon the work, and the callback/promise would fire after the result was no longer needed.

Extensibility

The configure() function, per-call quantizer/loader options, and the Quantizer/PixelLoader interfaces let power users:

  • Swap in the WASM quantizer for ~2–5x faster quantization on large palettes — globally via configure() or per-call via { quantizer: wasmQ }
  • Use a custom image decoder (e.g. @napi-rs/image, jimp, or a GPU-accelerated decoder) instead of sharp
  • Implement a completely different quantization algorithm (k-means, octree, etc.) and plug it in
  • Mix quantizers per extraction without reconfiguring globals

Conditional exports

Modern bundlers (webpack 5+, Vite, Rollup, esbuild) and Node.js 16+ resolve the correct build via the exports map. Browser builds never include sharp or Node.js loader code. Node builds never include DOM/canvas code.


Negatives and costs for consumers

Breaking changes — migration effort required

Every call site must be updated:

  1. Import syntax changes. new ColorThief() and require('colorthief') become import { getColor, getPalette } from 'colorthief'.
  2. Return type changes. [r, g, b] arrays become Color objects. Any code that destructures or indexes into the result (color[0]) will break. Use color.array() for the v2-style tuple.
  3. Primary browser API is now async. v2's synchronous getColor(img) becomes await getColor(img). Alternatively, use getColorSync(img) to keep synchronous call sites — but note that the sync variants only accept browser sources and don't support Workers or AbortSignal.
  4. Positional arguments removed. getPalette(img, 5, 10) must become getPalette(img, { colorCount: 5, quality: 10 }).
  5. Legacy methods gone. getColorFromUrl(), getColorAsync(), and getImageData() are removed. Use getColor() with a loaded HTMLImageElement.

For projects with many call sites, this migration is non-trivial.

Two API surfaces for browser

v2 had a single synchronous browser API. v3 has both async and sync variants (getColor/getColorSync, getPalette/getPaletteSync, getSwatches/getSwatchesSync). The sync functions restore v2's simplicity for browser-only use cases, but the dual surface means developers need to understand when to use which. The guidance is straightforward (sync for simple browser usage, async for Node.js/Workers/cancellation), but it's still two things to learn instead of one.

Color objects have overhead

The Color object is heavier than a raw [r, g, b] array. Each color allocates an object with methods, lazy-cached properties, and closure references. For the common case (palette of 5–20 colors), this overhead is negligible. But if you're extracting palettes from hundreds of images in a batch pipeline and only need RGB values, the object allocation is wasted work. Use color.array() to get the raw tuple if that's all you need.

sharp is no longer auto-installed

v2 installed sharp as a direct dependency — npm install colorthief gave you a working Node.js setup. v3 makes sharp an optional peer dependency. Node.js users must now run npm install sharp separately, and they'll see a peer dependency warning if they don't. This is a common source of confusion for new users who copy a Node.js example and get sharp is required for Node.js image loading at runtime.

ESM-only package

The package now has "type": "module". While CJS entry points are provided via the exports map, tools and environments that don't support the exports field may have trouble resolving the correct file. Older bundlers (webpack 4, older Jest configs without ESM support) may need configuration changes. The main field still points to a CJS file for fallback, but ESM-unaware tools may not handle the conditional exports correctly.

No synchronous Node.js API

v2's Node API was already async, so this isn't a regression there. The sync functions (getColorSync, etc.) are browser-only — they require DOM APIs (canvas, getImageData) that don't exist in Node.js. If you need synchronous color extraction in a Node.js context (e.g. inside a --loader hook or a synchronous build step), v3 doesn't support it.

New minimum Node.js version implied

The package targets ES2020 and uses ??, ?., AbortSignal, and AsyncGenerator. While it doesn't enforce an engines field, it effectively requires Node.js 16+ (and realistically 18+ for full AbortSignal support). Projects stuck on Node 14 cannot use v3.

WASM quantizer requires a separate build step

The Rust WASM quantizer is included as source code (src/wasm/) but is not pre-compiled. Using it requires:

  1. Installing the Rust toolchain and wasm-pack
  2. Running wasm-pack build --target web in src/wasm/
  3. Pointing WasmQuantizer at the generated .wasm file

This is a power-user feature and is clearly documented as such, but it's not a plug-and-play experience like the default MMCQ quantizer.

Larger browser bundle

v2's browser UMD was ~9 KB unminified. v3's IIFE bundle is ~19 KB minified (including the Color object, OKLCH conversions, swatch classification, progressive extraction, and worker manager). For projects that only need basic getColor(), roughly half the bundle is unused features. Tree-shaking via the ESM build mitigates this — if you only import getColor, bundlers can eliminate the rest — but the UMD/IIFE build carries everything.

Swatch classification may return null for some roles

getSwatches() returns a SwatchMap where any role can be null if no palette color falls within that role's OKLCH lightness/chroma range. For images with limited color variation (e.g. a mostly blue photo), you may get null for LightVibrant or DarkMuted. Consumers must null-check every swatch access. This is inherent to the classification approach, but it means you can't rely on getting all six swatches.

No UMD class wrapper

v2 exposed a ColorThief class via the UMD build, which some projects instantiated as new ColorThief(). v3's UMD build exposes ColorThief.getColor(), ColorThief.getPalette(), ColorThief.getColorSync(), etc. as direct functions — there's no class to instantiate. This breaks any code that does const ct = new ColorThief().


Migration cheatsheet

v2v3 (async)v3 (sync, browser only)
const ct = new ColorThief()Remove — no class neededRemove — no class needed
ct.getColor(img)await getColor(img)getColorSync(img)
ct.getColor(img, 5)await getColor(img, { quality: 5 })getColorSync(img, { quality: 5 })
ct.getPalette(img, 8)await getPalette(img, { colorCount: 8 })getPaletteSync(img, { colorCount: 8 })
ct.getPalette(img, 8, 5)await getPalette(img, { colorCount: 8, quality: 5 })getPaletteSync(img, { colorCount: 8, quality: 5 })
color[0], color[1], color[2]color.array()[0] or color.rgb().rsame
'#' + color.map(...)color.hex() or color.toString()same
ct.getColorFromUrl(url, cb)Load the image yourself, then await getColor(img)Load the image, then getColorSync(img)
require('colorthief').getColor(path)import { getColor } from 'colorthief'N/A (sync is browser only)