V3.md
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.
src/ is now .ts. Strict mode is enabled. Published .d.ts declarations are generated from the source (no more hand-maintained type stubs).dist/browser/ — ESM (.js) and CJS (.cjs) for browsersdist/node/ — ESM (.js) and CJS (.cjs) for Node.jsdist/umd/ — Minified IIFE exposing a ColorThief globaldist/types/ — .d.ts and .d.cts declarations"type": "module". The package is now ESM-first. CJS consumers use the .cjs entry points via the conditional exports map.exports map. Bundlers and runtimes that support the exports field in package.json will automatically resolve to the correct browser or Node build.| Concern | v2 | v3 |
|---|---|---|
| Browser import | new ColorThief() class, methods on prototype | Named function imports: getColor(), getPalette(), getSwatches(), etc. |
| Node import | require('colorthief') returns { getColor, getPalette } | Same named imports as browser: import { getColor } from 'colorthief' |
| Browser return type | Synchronous [r, g, b] tuple or null | Promise<Color | null> |
| Node return type | Promise<[r, g, b] | null> | Promise<Color | null> (same as browser) |
| Options | Positional args (img, colorCount, quality) or options object | Single options object only: { colorCount, quality, ... } |
| Legacy methods | getColorFromUrl(), 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).
v2 returned raw [r, g, b] arrays. v3 returns Color objects:
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.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).For browser-only use cases where you don't need Worker offloading, AbortSignal, or Node.js support, v3 provides synchronous variants:
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:
await, no async context)Use the async API when:
In addition to the global configure(), you can pass quantizer and loader per-call:
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.
getSwatches())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.
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.
getPaletteProgressive())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.
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.
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.
configure() and per-call options)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.
A Rust implementation of the full MMCQ algorithm lives in src/wasm/. It implements:
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.
| v2 | v3 | |
|---|---|---|
sharp | Direct dependency (always installed) | Optional peer dependency (only needed for Node.js) |
ndarray-pixels | Direct dependency | Removed entirely — sharp's .raw().toBuffer() is used directly |
@lokesh.dhakar/quantize | Direct dependency | Direct dependency (unchanged) |
typescript | Not present | devDependency |
tsup | Not present | devDependency (replaces microbundle) |
microbundle | devDependency | Removed |
The package has two entry points to keep the common-case autocomplete clean:
// 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.
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
The test suite was rewritten:
| v2 | v3 | |
|---|---|---|
| Node test count | 22 tests | 50 tests |
| Test format | CommonJS (require) | ESM (import) |
| Assertions | Check [r,g,b] arrays | Check Color objects (.rgb(), .hex(), .isDark, etc.) |
New test coverage areas:
getSwatches() structure and role assignmentv2 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:
// 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();
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.
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.
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.
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.
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.
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.
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.
The configure() function, per-call quantizer/loader options, and the Quantizer/PixelLoader interfaces let power users:
configure() or per-call via { quantizer: wasmQ }@napi-rs/image, jimp, or a GPU-accelerated decoder) instead of sharpModern 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.
Every call site must be updated:
new ColorThief() and require('colorthief') become import { getColor, getPalette } from 'colorthief'.[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.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.getPalette(img, 5, 10) must become getPalette(img, { colorCount: 5, quality: 10 }).getColorFromUrl(), getColorAsync(), and getImageData() are removed. Use getColor() with a loaded HTMLImageElement.For projects with many call sites, this migration is non-trivial.
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.
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-installedv2 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.
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.
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.
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.
The Rust WASM quantizer is included as source code (src/wasm/) but is not pre-compiled. Using it requires:
wasm-packwasm-pack build --target web in src/wasm/WasmQuantizer at the generated .wasm fileThis 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.
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.
null for some rolesgetSwatches() 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.
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().
| v2 | v3 (async) | v3 (sync, browser only) |
|---|---|---|
const ct = new ColorThief() | Remove — no class needed | Remove — 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().r | same |
'#' + 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) |