Showing preview only (274K chars total). Download the full file or copy to clipboard to get everything.
Repository: lokesh/color-thief
Branch: master
Commit: 01dc0f3a8759
Files: 59
Total size: 258.3 KB
Directory structure:
gitextract_gvnyy151/
├── .editorconfig
├── .github/
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── .mocharc.yml
├── .nvmrc
├── LICENSE
├── PLAN.md
├── README.md
├── V3.md
├── async.html
├── build/
│ └── build.js
├── cypress/
│ ├── e2e/
│ │ ├── api-direct.cy.js
│ │ ├── api.cy.js
│ │ ├── cors.cy.js
│ │ └── module.cy.js
│ ├── fixtures/
│ │ └── example.json
│ ├── plugins/
│ │ └── index.cjs
│ ├── support/
│ │ ├── commands.js
│ │ └── e2e.js
│ └── test-pages/
│ ├── api-direct.html
│ ├── cors.html
│ ├── es6-module.html
│ ├── index.html
│ ├── index.js
│ └── screen.css
├── cypress.config.cjs
├── examples/
│ ├── css/
│ │ └── screen.css
│ └── js/
│ └── demo.js
├── index.html
├── package.json
├── src/
│ ├── api.ts
│ ├── cli.ts
│ ├── color-space.ts
│ ├── color.ts
│ ├── index.ts
│ ├── internals.browser.ts
│ ├── internals.ts
│ ├── loaders/
│ │ ├── browser.ts
│ │ └── node.ts
│ ├── observe.ts
│ ├── pipeline.ts
│ ├── progressive.ts
│ ├── quantizers/
│ │ ├── mmcq.ts
│ │ └── wasm.ts
│ ├── resolve-loader.browser.ts
│ ├── resolve-loader.ts
│ ├── swatches.ts
│ ├── sync.ts
│ ├── types.ts
│ ├── umd.ts
│ ├── wasm/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── lib.rs
│ └── worker/
│ ├── manager.ts
│ └── worker-script.ts
├── test/
│ ├── cli-test.js
│ ├── node-cjs-test.cjs
│ └── node-test.js
├── tsconfig.json
└── tsup.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = space
indent_size = 4
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches: [master, dev]
pull_request:
branches: [master]
jobs:
node-tests:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build
- run: npm run test:node
browser-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Start http-server
run: npx http-server -p 8080 &
- name: Wait for server
run: npx wait-on http://localhost:8080
- run: npx cypress run --config video=false
================================================
FILE: .gitignore
================================================
*.log
*.sql
*.sqlite
.htaccess
.ftppass
.host_config
*.DS_Store
ehthumbs.db
Icon?
Thumbs.db
.sass-cache
Rakefile
rsync-exclude
node_modules
dist
.idea
CLAUDE.md
*.tsbuildinfo
================================================
FILE: .mocharc.yml
================================================
spec: test/**/*.{js,cjs}
timeout: 10000
================================================
FILE: .nvmrc
================================================
18.20.4
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2015 Lokesh Dhakar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: PLAN.md
================================================
# PLAN.md
## Phase 1: v2 — Non-breaking improvements
Improvements that ship under the current API contract. No breaking changes. Existing consumers upgrade without code changes.
### 1A: Fix critical bugs
- **Handle white/single-color images.** When the pixel filter strips all pixels (all white, all transparent, single color), return a sensible fallback instead of `null`. `getColor()` should return the dominant remaining color (or the image's actual color if filtering removed everything). `getPalette()` should return a shorter array rather than crash.
- **Fix variable scope leak.** `src/color-thief.js:120` — `i = uInt8Array.length` is missing `let`, creating an implicit global.
- **Add input validation with clear error messages.** Throw descriptive errors for: missing/unloaded image elements, tainted canvases (CORS), invalid image sources. Currently these fail silently or throw cryptic browser errors.
### 1B: TypeScript type definitions
- Ship a `dist/color-thief.d.ts` and `dist/color-thief-node.d.ts` alongside the existing JS output.
- Add `types` field to `package.json`.
- No source rewrite — just hand-authored `.d.ts` files that match the current API.
### 1C: Accept more input types (browser)
Expand what `getColor()` and `getPalette()` accept beyond `HTMLImageElement`:
- `HTMLCanvasElement`
- `ImageData`
- `ImageBitmap`
These are additive — existing code passing `<img>` elements still works.
### 1D: Configurable pixel filtering
Expose the hardcoded thresholds as optional config:
- `ignoreWhite` (default `true`, current behavior) — with configurable RGB threshold (default 250)
- `alphaThreshold` (default 125) — pixels below this alpha are skipped
- `minSaturation` (default 0) — optional minimum saturation filter
Pass as an options object: `getColor(image, { quality: 10, ignoreWhite: false })`. The current positional args (`quality`, `colorCount`) continue to work for backward compat.
### 1E: Update dev tooling
- Upgrade ESLint v5 → v9 with flat config.
- Update `ecmaVersion` from 2018 to current.
- Add a CI workflow (GitHub Actions) for automated test runs on PRs.
- Drop the misleading `color-thief.min.js` copy — or actually minify it.
---
## Phase 2: v3 — Breaking changes and new architecture
A new major version. Different API surface, new output format, modern JS throughout. Published as a new major version with a migration guide.
### 2A: TypeScript rewrite and unified codebase
- Rewrite all source files in TypeScript.
- Merge the browser and Node implementations into a single codebase with platform-specific adapters for pixel loading. The core algorithm, pixel filtering, and output formatting are shared. Only the "get pixels from image" step differs.
- Single API surface for both platforms. No more class on browser / bare functions on Node.
### 2B: Modern async API
- Promise-based everywhere. `getColor()` and `getPalette()` return Promises on both browser and Node.
- Drop `getColorFromUrl()`, `getColorAsync()`, and `getImageData()`. Image loading is the consumer's responsibility.
- Replace `XMLHttpRequest` with `fetch()` if any internal HTTP calls remain.
- Support `AbortController` / `AbortSignal` for cancellation.
### 2C: Rich output format
Replace bare `[r, g, b]` arrays with color objects:
```
const color = await colorThief.getColor(image);
color.rgb() // { r, g, b }
color.hex() // '#e84d3d'
color.hsl() // { h, s, l }
color.oklch() // { l, c, h }
color.array() // [r, g, b] (backward-compat escape hatch)
color.isDark // boolean
```
`getPalette()` returns an array of these objects.
### 2D: Semantic swatches
Add a `getSwatches()` method that classifies palette colors into UI roles:
- Vibrant, Muted, DarkVibrant, DarkMuted, LightVibrant, LightMuted
- Each swatch includes a suggested text color (title and body) for accessibility.
- Inspired by Android's Palette API / node-vibrant, but with OKLCH-based classification for better perceptual accuracy.
### 2E: Web Worker support
- Use `OffscreenCanvas` + `createImageBitmap` to move pixel reading and quantization off the main thread.
- Opt-in via config: `getColor(image, { worker: true })`.
- Fallback to synchronous main-thread processing when Workers or OffscreenCanvas are unavailable.
### 2F: Lighter Node.js dependencies
- Remove `sharp` as a hard dependency. It's heavy (native bindings, Docker/CI build issues).
- Make image decoding pluggable — consumers bring their own decoder, or use a built-in lightweight default.
- Consider `sharp` as an optional peer dependency for users who already have it.
### 2G: Optional WASM backend
- Ship a `@colorthief/wasm` package with the quantization algorithm compiled from Rust.
- Same API as the pure-JS version — drop-in replacement for the core.
- ~6x performance improvement for the compute-heavy pixel clustering step.
- The main `colorthief` package stays pure JS with zero native dependencies.
### 2H: OKLCH-native pipeline
- Option to perform quantization in OKLCH color space instead of RGB.
- Produces more perceptually distinct palettes — colors that look different to humans, not just mathematically distant in RGB.
- Default remains RGB quantization for performance and backward compat. OKLCH mode is opt-in.
### 2I: Accessibility built in
- For each color in a palette, include:
- WCAG contrast ratios against white and black
- `isDark` / `isLight` boolean
- Suggested foreground text color (white or black) for AA compliance
- Make this zero-config — always included in the output, no extra method calls.
### 2J: Progressive extraction
- For large images, return an approximate palette immediately from a downsampled pass, then refine progressively.
- API: `getColor(image, { progressive: true })` returns an async iterator or observable that emits improving results.
- Useful for large images, batch processing, and perceived performance.
- No competitor currently offers this.
================================================
FILE: README.md
================================================
# Color Thief
> Extract dominant colors and palettes from images in the browser and Node.js.
[](https://www.npmjs.com/package/colorthief)
[](https://bundlephobia.com/package/colorthief)
[](https://www.npmjs.com/package/colorthief)
## Install
```bash
npm install colorthief
```
Or load directly from a CDN:
```html
<script src="https://unpkg.com/colorthief@3/dist/umd/color-thief.global.js"></script>
```
## Quick Start
```js
import { getColorSync, getPaletteSync, getSwatches } from 'colorthief';
// Dominant color
const color = getColorSync(img);
color.hex(); // '#e84393'
color.css(); // 'rgb(232, 67, 147)'
color.isDark; // false
color.textColor; // '#000000'
// Palette
const palette = getPaletteSync(img, { colorCount: 6 });
palette.forEach(c => console.log(c.hex()));
// Semantic swatches (Vibrant, Muted, DarkVibrant, etc.)
const swatches = await getSwatches(img);
swatches.Vibrant?.color.hex();
```
## Features
- **TypeScript** — full type definitions included
- **Browser + Node.js** — same API, both platforms
- **Sync & async** — synchronous browser API, async for Node.js and Web Workers
- **Live extraction** — `observe()` watches video, canvas, or img elements and emits palette updates reactively
- **Web Workers** — offload quantization off the main thread with `worker: true`
- **Progressive extraction** — 3-pass refinement for instant rough results
- **OKLCH quantization** — perceptually uniform palettes via `colorSpace: 'oklch'`
- **Semantic swatches** — Vibrant, Muted, DarkVibrant, DarkMuted, LightVibrant, LightMuted
- **Rich Color objects** — `.hex()`, `.rgb()`, `.hsl()`, `.oklch()`, `.css()`, contrast ratios, text color recommendations
- **WCAG contrast** — `color.contrast.white`, `color.contrast.black`, `color.contrast.foreground`
- **AbortSignal** — cancel in-flight extractions
- **CLI** — `colorthief photo.jpg` with JSON, CSS, and ANSI output
- **Zero runtime dependencies**
## API at a Glance
| Function | Description |
|---|---|
| `getColorSync(source, options?)` | Dominant color (sync, browser only) |
| `getPaletteSync(source, options?)` | Color palette (sync, browser only) |
| `getSwatchesSync(source, options?)` | Semantic swatches (sync, browser only) |
| `getColor(source, options?)` | Dominant color (async, browser + Node.js) |
| `getPalette(source, options?)` | Color palette (async, browser + Node.js) |
| `getSwatches(source, options?)` | Semantic swatches (async, browser + Node.js) |
| `getPaletteProgressive(source, options?)` | 3-pass progressive palette (async generator) |
| `observe(source, options)` | Watch a source and emit palette updates (browser only) |
| `createColor(r, g, b, population)` | Build a Color object from RGB values |
### Options
| Option | Default | Description |
|---|---|---|
| `colorCount` | `10` | Number of palette colors (2–20) |
| `quality` | `10` | Sampling rate (1 = every pixel, 10 = every 10th) |
| `colorSpace` | `'oklch'` | Quantization space: `'rgb'` or `'oklch'` |
| `worker` | `false` | Offload to Web Worker (browser only) |
| `signal` | — | `AbortSignal` to cancel extraction |
| `ignoreWhite` | `true` | Skip white pixels |
### Color Object
| Property / Method | Returns |
|---|---|
| `.rgb()` | `{ r, g, b }` |
| `.hex()` | `'#ff8000'` |
| `.hsl()` | `{ h, s, l }` |
| `.oklch()` | `{ l, c, h }` |
| `.css(format?)` | `'rgb(255, 128, 0)'`, `'hsl(…)'`, or `'oklch(…)'` |
| `.array()` | `[r, g, b]` |
| `.toString()` | Hex string (works in template literals) |
| `.textColor` | `'#ffffff'` or `'#000000'` |
| `.isDark` / `.isLight` | Boolean |
| `.contrast` | `{ white, black, foreground }` — WCAG ratios |
| `.population` | Raw pixel count |
| `.proportion` | 0–1 share of total |
## Browser
```js
import { getColorSync, getPaletteSync } from 'colorthief';
const img = document.querySelector('img');
const color = getColorSync(img);
console.log(color.hex());
const palette = getPaletteSync(img, { colorCount: 5 });
```
Accepts `HTMLImageElement`, `HTMLCanvasElement`, `HTMLVideoElement`, `ImageData`, `ImageBitmap`, and `OffscreenCanvas`.
### Live extraction with observe()
```js
import { observe } from 'colorthief';
// Watch a video and update ambient lighting as it plays
const controller = observe(videoElement, {
throttle: 200, // ms between updates
colorCount: 5,
onChange(palette) {
updateAmbientBackground(palette);
},
});
// Stop when done
controller.stop();
```
Works with `<video>`, `<canvas>`, and `<img>` elements. For images, it uses a MutationObserver to detect `src` changes. For video and canvas, it polls using requestAnimationFrame with throttle.
## Node.js
```js
import { getColor, getPalette } from 'colorthief';
const color = await getColor('/path/to/image.jpg');
console.log(color.hex());
const palette = await getPalette(Buffer.from(data), { colorCount: 5 });
```
Accepts file paths and Buffers. Uses [sharp](https://sharp.pixelplumbing.com/) for image decoding.
## CLI
### Quick start
```bash
npx colorthief-cli photo.jpg
```
The `colorthief-cli` package bundles everything needed (including sharp for image
decoding), so it works immediately with no extra setup.
### Commands
```bash
# Dominant color
colorthief-cli photo.jpg
# Color palette
colorthief-cli palette photo.jpg
# Semantic swatches
colorthief-cli swatches photo.jpg
```
### Output formats
```bash
# Default: ANSI color swatches
colorthief-cli photo.jpg
# ▇▇ #e84393
# JSON with full color data
colorthief-cli photo.jpg --json
# CSS custom properties
colorthief-cli palette photo.jpg --css
# :root {
# --color-1: #e84393;
# --color-2: #6c5ce7;
# }
```
### Options
```bash
colorthief-cli palette photo.jpg --count 5 # Number of colors (2-20)
colorthief-cli photo.jpg --quality 1 # Sampling quality (1=best)
colorthief-cli photo.jpg --color-space rgb # Color space (rgb or oklch)
```
Stdin is supported — use `-` or pipe directly:
```bash
cat photo.jpg | colorthief-cli -
```
Multiple files are supported. Output is prefixed with filenames, and `--json` wraps
results in an object keyed by filename.
> **Note:** If you already have `colorthief` and `sharp` installed in a project, you
> can also use `colorthief` directly as the command name (without the `-cli` suffix).
## Links
- [Demo page & live examples](https://lokeshdhakar.com/projects/color-thief/)
- [GitHub](https://github.com/lokesh/color-thief)
- [npm](https://www.npmjs.com/package/colorthief)
## Contributing
```bash
npm run build # Build all dist formats
npm run test # Run all tests (Mocha + Cypress)
npm run test:node # Node tests only
npm run test:browser # Browser tests (requires npm run dev)
npm run dev # Start local server on port 8080
```
## Releasing
```bash
# 1. Make sure you're on master with a clean working tree
git status
# 2. Run the full test suite
npm run build
npm run test:node
npm run test:browser # requires npm run dev in another terminal
# 3. Preview what will be published
npm pack --dry-run
# 4. Tag and publish
npm version <major|minor|patch> # bumps version, creates git tag
npm publish # builds via prepublishOnly, then publishes
git push && git push --tags
```
## License
[MIT](LICENSE) - Lokesh Dhakar
================================================
FILE: V3.md
================================================
# 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
| 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).
### 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
| | 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 |
### 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:
| | 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:
- 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
| 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) |
================================================
FILE: async.html
================================================
<!doctype html>
<html class="no-js" lang="en">
<head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script src="dist/color-thief.min.js"></script>
</head>
<body>
<header>
<div class="container">
<div id="samedomain">
<img src="examples/img/image-1.jpg" width="400" height="200">
<h1>Same domain, image by url</h1>
</div>
<script type="text/javascript">
var colorThief = new ColorThief();
colorSync = colorThief.getColorFromUrl("examples/img/image-1.jpg", function(color){
$('#samedomain').css('background-color','rgb('+color[0]+','+color[1]+','+color[2]+')')
console.log('url',color)
});
</script>
<div id="crossdomain">
<img width="400" height="200">
<h1>Cross-domain, image by url</h1>
</div>
<script type="text/javascript">
colorThief.getColorAsync("https://lokeshdhakar.com/media/posts/color-thief/color-thief-pixels.png",function(color, element){
$('#crossdomain').css('background-color','rgb('+color[0]+','+color[1]+','+color[2]+')')
$('#crossdomain img').attr('src',element.src)
console.log('async', color, element.src)
});
</script>
</div>
</body>
</html>
================================================
FILE: build/build.js
================================================
var fs = require('fs');
const { resolve } = require('path');
/*
color-thief.umd.js duplicated as color-thief.min.js for legacy support
In Color Thief v2.1 <= there was one distribution file (dist/color-thief.min.js)
and it exposed a global variable ColorThief. Starting from v2.2, the package
includes multiple dist files for the various module systems. One of these is
the UMD format which falls back to a global variable if the requirejs AMD format
is not being used. This file is called color-thief.umd.js in the dist folder. We
want to keep supporting the previous users who were loading
dist/color-thief.min.js and expecting a global var. For this reason we're
duplicating the UMD compatible file and giving it that name.
Note: Microbundle already minifies the UMD output, so color-thief.min.js
is genuinely minified — the copy IS minified code.
*/
const umdRelPath = 'dist/color-thief.umd.js';
const legacyRelPath = 'dist/color-thief.min.js';
const umdPath = resolve(process.cwd(), umdRelPath);
const legacyPath = resolve(process.cwd(), legacyRelPath);
fs.copyFile(umdPath, legacyPath, (err) => {
if (err) throw err;
console.log(`${umdRelPath} copied to ${legacyRelPath}.`);
});
const srcNodeRelPath = 'src/color-thief-node.js';
const distNodeRelPath = 'dist/color-thief.js';
const srcNodePath = resolve(process.cwd(), srcNodeRelPath);
const distNodePath = resolve(process.cwd(), distNodeRelPath);
fs.copyFile(srcNodePath, distNodePath, (err) => {
if (err) throw err;
console.log(`${srcNodeRelPath} copied to ${distNodeRelPath}.`);
});
// Copy TypeScript declaration files to dist
const typeCopies = [
['src/color-thief.d.ts', 'dist/color-thief.d.ts'],
['src/color-thief-node.d.ts', 'dist/color-thief-node.d.ts'],
];
typeCopies.forEach(([srcRel, distRel]) => {
const srcPath = resolve(process.cwd(), srcRel);
const distPath = resolve(process.cwd(), distRel);
fs.copyFile(srcPath, distPath, (err) => {
if (err) throw err;
console.log(`${srcRel} copied to ${distRel}.`);
});
});
================================================
FILE: cypress/e2e/api-direct.cy.js
================================================
describe('Direct API - getColorSync()', { testIsolation: false }, function() {
before(function() {
cy.visit('http://localhost:8080/cypress/test-pages/api-direct.html');
cy.get('body[data-ready="true"]', { timeout: 10000 });
});
it('returns near-black for black.png', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-black');
const color = win.ColorThief.getColorSync(img);
const [r, g, b] = color.array();
expect(r).to.be.lessThan(10);
expect(g).to.be.lessThan(10);
expect(b).to.be.lessThan(10);
});
});
it('returns near-red for red.png', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-red');
const color = win.ColorThief.getColorSync(img);
const [r, g, b] = color.array();
expect(r).to.be.greaterThan(240);
expect(g).to.be.lessThan(15);
expect(b).to.be.lessThan(15);
});
});
it('returns near-white for white.png', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-white');
const color = win.ColorThief.getColorSync(img);
const [r, g, b] = color.array();
expect(r).to.be.greaterThan(240);
expect(g).to.be.greaterThan(240);
expect(b).to.be.greaterThan(240);
});
});
it('returns valid color for transparent.png', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-transparent');
const color = win.ColorThief.getColorSync(img);
const rgb = color.array();
expect(rgb).to.have.lengthOf(3);
});
});
it('respects quality parameter', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-rainbow');
const color1 = win.ColorThief.getColorSync(img, { quality: 1 });
const color100 = win.ColorThief.getColorSync(img, { quality: 100 });
expect(color1.array()).to.have.lengthOf(3);
expect(color100.array()).to.have.lengthOf(3);
});
});
});
describe('Direct API - getPaletteSync()', { testIsolation: false }, function() {
before(function() {
cy.visit('http://localhost:8080/cypress/test-pages/api-direct.html');
cy.get('body[data-ready="true"]', { timeout: 10000 });
});
it('returns default 10 colors', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-rainbow');
const palette = win.ColorThief.getPaletteSync(img);
expect(palette).to.have.lengthOf(10);
palette.forEach(color => {
expect(color.array()).to.have.lengthOf(3);
});
});
});
it('returns palette with white for white.png', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-white');
const palette = win.ColorThief.getPaletteSync(img);
expect(palette).to.be.an('array').that.has.lengthOf(1);
const [r, g, b] = palette[0].array();
expect(r).to.be.greaterThan(240);
expect(g).to.be.greaterThan(240);
expect(b).to.be.greaterThan(240);
});
});
it('returns valid palette for transparent.png', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-transparent');
const palette = win.ColorThief.getPaletteSync(img);
expect(palette).to.be.an('array').that.is.not.empty;
palette.forEach(color => expect(color.array()).to.have.lengthOf(3));
});
});
it('throws when colorCount=1', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-rainbow');
expect(() => win.ColorThief.getPaletteSync(img, { colorCount: 1 })).to.throw();
});
});
it('clamps colorCount=0 to 2', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-rainbow');
const palette = win.ColorThief.getPaletteSync(img, { colorCount: 0 });
expect(palette).to.have.lengthOf(2);
});
});
it('clamps colorCount=21 to 20', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-rainbow');
const palette = win.ColorThief.getPaletteSync(img, { colorCount: 21 });
expect(palette).to.have.lengthOf(20);
});
});
});
describe('Direct API - Input Types', { testIsolation: false }, function() {
before(function() {
cy.visit('http://localhost:8080/cypress/test-pages/api-direct.html');
cy.get('body[data-ready="true"]', { timeout: 10000 });
});
it('accepts HTMLCanvasElement input', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-red');
const canvas = win.document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
const color = win.ColorThief.getColorSync(canvas);
const [r] = color.array();
expect(r).to.be.greaterThan(240);
});
});
it('accepts ImageData input', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-red');
const canvas = win.document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const color = win.ColorThief.getColorSync(imageData);
const [r] = color.array();
expect(r).to.be.greaterThan(240);
});
});
it('accepts ImageBitmap input', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-red');
return win.createImageBitmap(img).then((bitmap) => {
const color = win.ColorThief.getColorSync(bitmap);
const [r] = color.array();
expect(r).to.be.greaterThan(240);
});
});
});
it('accepts options object with HTMLCanvasElement', function() {
cy.window().then((win) => {
const img = win.document.getElementById('img-rainbow');
const canvas = win.document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
const palette = win.ColorThief.getPaletteSync(canvas, { colorCount: 5 });
expect(palette).to.have.lengthOf(5);
});
});
});
================================================
FILE: cypress/e2e/api.cy.js
================================================
function rgbCount(text) {
const vals = text.split(',');
for (const val of vals) {
if (val < 0 || val > 255) {
throw 'Invalid RGB color value';
}
}
return vals.length / 3
}
describe('getColorSync()', { testIsolation: false }, function() {
before(function() {
cy.visit('http://localhost:8080/cypress/test-pages/index.html');
})
it('returns valid color from black image', function() {
cy.get('[data-image="black.png"] .output-color').should(($el) => {
const count = rgbCount($el.text())
expect(count).to.equal(1);
const [r, g, b] = $el.text().split(',').map(Number);
expect(r).to.be.lessThan(10);
expect(g).to.be.lessThan(10);
expect(b).to.be.lessThan(10);
});
})
it('returns valid color from red image', function() {
cy.get('[data-image="red.png"] .output-color').should(($el) => {
const count = rgbCount($el.text())
expect(count).to.equal(1);
const [r, g, b] = $el.text().split(',').map(Number);
expect(r).to.be.greaterThan(240);
expect(g).to.be.lessThan(15);
expect(b).to.be.lessThan(15);
});
})
it('returns valid color from rainbow image', function() {
cy.get('[data-image="rainbow-horizontal.png"] .output-color').should(($el) => {
const count = rgbCount($el.text())
expect(count).to.equal(1);
});
})
it('returns valid color from white image', function() {
cy.get('[data-image="white.png"] .output-color').should(($el) => {
const count = rgbCount($el.text())
expect(count).to.equal(1);
});
})
it('returns valid color from transparent image', function() {
cy.get('[data-image="transparent.png"] .output-color').should(($el) => {
const count = rgbCount($el.text())
expect(count).to.equal(1);
});
})
})
function testPaletteCount(num) {
it(`returns ${num} color when colorCount set to ${num}`, function() {
cy.get(`[data-image="rainbow-horizontal.png"] .palette[data-count="${num}"] .output-palette`).should(($el) => {
const count = rgbCount($el.text())
expect(count).to.equal(num);
});
})
}
describe('getPaletteSync()', function() {
beforeEach(function() {
cy.visit('http://localhost:8080/cypress/test-pages/index.html');
})
let testCounts = [2, 3, 5, 7, 10, 20];
testCounts.forEach((count) => testPaletteCount(count))
})
================================================
FILE: cypress/e2e/cors.cy.js
================================================
describe('cross domain images with liberal CORS policy', function() {
it('load', function() {
cy.visit('http://localhost:8080/cypress/test-pages/cors.html');
cy.get('#result').should(($el) => {
const count = $el.text().split(',').length
expect(count).to.equal(3);
});
})
});
================================================
FILE: cypress/e2e/module.cy.js
================================================
describe('es6 module', function() {
it('loads', function() {
cy.visit('http://localhost:8080/cypress/test-pages/es6-module.html');
cy.get('#result').should(($el) => {
const count = $el.text().split(',').length
expect(count).to.equal(3);
});
})
});
================================================
FILE: cypress/fixtures/example.json
================================================
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}
================================================
FILE: cypress/plugins/index.cjs
================================================
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}
================================================
FILE: cypress/support/commands.js
================================================
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
================================================
FILE: cypress/support/e2e.js
================================================
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
================================================
FILE: cypress/test-pages/api-direct.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Color Thief - Direct API Tests</title>
</head>
<body>
<img id="img-black" src="./img/black.png" crossorigin="anonymous" />
<img id="img-red" src="./img/red.png" crossorigin="anonymous" />
<img id="img-rainbow" src="./img/rainbow-horizontal.png" crossorigin="anonymous" />
<img id="img-white" src="./img/white.png" crossorigin="anonymous" />
<img id="img-transparent" src="./img/transparent.png" crossorigin="anonymous" />
<script src="/dist/umd/color-thief.global.js"></script>
<script>
var images = document.querySelectorAll('img');
var loaded = 0;
var total = images.length;
function checkAllLoaded() {
loaded++;
if (loaded === total) {
document.body.setAttribute('data-ready', 'true');
}
}
images.forEach(function(img) {
if (img.complete) {
checkAllLoaded();
} else {
img.addEventListener('load', checkAllLoaded);
img.addEventListener('error', checkAllLoaded);
}
});
</script>
</body>
</html>
================================================
FILE: cypress/test-pages/cors.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>Color Thief</title>
<link rel="stylesheet" href="./screen.css">
</head>
<body>
<img src="https://color-thief-cors-test-images.s3-us-west-1.amazonaws.com/boise-spacebar-arcade.jpeg" crossorigin="anonymous" />
<div id="result"></div>
<script src="/dist/umd/color-thief.global.js"></script>
<script>
const img = document.querySelector('img');
function getColorFromImage(img) {
const result = ColorThief.getColorSync(img);
document.querySelector('#result').innerText = result ? result.array().toString() : 'null';
}
if (img.complete) {
getColorFromImage(img);
} else {
img.addEventListener('load', function() {
getColorFromImage(img);
});
}
</script>
</body>
</html>
================================================
FILE: cypress/test-pages/es6-module.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>Color Thief</title>
<link rel="stylesheet" href="./screen.css">
</head>
<body>
<img src="./img/rainbow-horizontal.png" />
<div id="result"></div>
<script type="module">
import { getColorSync } from '../../dist/index.js';
const image = document.querySelector('img');
function getColorFromImage(img) {
const result = getColorSync(img);
document.querySelector('#result').innerText = result ? result.array().toString() : 'null';
}
if (image.complete) {
getColorFromImage(image);
} else {
image.addEventListener('load', function() {
getColorFromImage(image);
});
}
</script>
</body>
</html>
================================================
FILE: cypress/test-pages/index.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>Color Thief</title>
<link rel="stylesheet" href="./screen.css">
</head>
<body>
<div id="example-images"></div>
<script id='image-tpl' type='text/x-mustache'>
{{#.}}
<div class="image-section" data-image="{{.}}">
<h2>{{.}}</h2>
<img class="image" src="./img/{{.}}" />
<div class="output"></div>
</div>
{{/.}}
</script>
<script id="color-tpl" type="text/x-mustache">
<div class="color">
<h3>getColor(img)</h3>
<div class="swatches">
<div class="swatch" style="background-color: rgb({{color.0}}, {{color.1}}, {{color.2}})"></div>
</div>
<code>
<div class="output-color">{{colorStr}}</div>
<div class="time">{{elapsedTime}}ms</div>
</code>
</div>
</script>
<script id="palette-tpl" type="text/x-mustache">
<div class="palette" data-count="{{count}}">
<h3>getPalette(img, {{count}})</h3>
<div class="swatches">
{{#palette}}
<div class="swatch" style="background-color: rgb({{0}}, {{1}}, {{2}})"></div>
{{/palette}}
</div>
<code>
<div class="output-palette">{{paletteStr}}</div>
<div class="time">{{elapsedTime}}ms</div>
</code>
</div>
</script>
<script src="/dist/umd/color-thief.global.js"></script>
<script src="/node_modules/mustache/mustache.js"></script>
<script src="index.js"></script>
</body>
</html>
================================================
FILE: cypress/test-pages/index.js
================================================
var images = [
'black.png',
'red.png',
'rainbow-horizontal.png',
'rainbow-vertical.png',
'transparent.png',
'white.png',
];
// Render example images
var examplesHTML = Mustache.to_html(document.getElementById('image-tpl').innerHTML, images);
document.getElementById('example-images').innerHTML = examplesHTML;
// Run Color Thief functions and display results below image.
// We also log execution time of functions for display.
const showColorsForImage = function(image, section) {
// getColorSync(img)
let start = Date.now();
let result = ColorThief.getColorSync(image);
let elapsedTime = Date.now() - start;
const rgb = result ? result.array() : null;
const colorHTML = Mustache.to_html(document.getElementById('color-tpl').innerHTML, {
color: rgb,
colorStr: rgb ? rgb.toString() : 'null',
elapsedTime
})
// getPaletteSync(img, { colorCount })
let paletteHTML = '';
let colorCounts = [2, 3, 5, 7, 10, 20];
colorCounts.forEach((count) => {
let start = Date.now();
let result = ColorThief.getPaletteSync(image, { colorCount: count });
let elapsedTime = Date.now() - start;
const rgbPalette = result ? result.map(c => c.array()) : null;
paletteHTML += Mustache.to_html(document.getElementById('palette-tpl').innerHTML, {
count,
palette: rgbPalette,
paletteStr: rgbPalette ? rgbPalette.toString() : 'null',
elapsedTime
})
});
const outputEl = section.querySelector('.output');
outputEl.innerHTML += colorHTML + paletteHTML;
};
// Once images are loaded, process them
document.querySelectorAll('.image').forEach((image) => {
const section = image.closest('.image-section');
if (image.complete) {
showColorsForImage(image, section);
} else {
image.addEventListener('load', function() {
showColorsForImage(image, section);
});
}
})
================================================
FILE: cypress/test-pages/screen.css
================================================
:root {
/* Colors */
--color: #000;
--bg-color: #f9f9f9;
--primary-color: #fc4c02;
--secondary-color: #f68727;
--muted-color: #999;
--code-color: var(--primary-color);
--code-bg-color: #fff;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--code-font: Menlo, Consolas, Monaco, Lucida Console, monospace;
--bold: 700;
--x-bold: 900;
--line-height: 1.5em;
--line-height-heading: 1.3em;
/* Breakpoints */
--sm-screen: 640px;
}
/* Base
* *----------------------------------------------- */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background: var(--bg-color);
}
/* Typography
* *----------------------------------------------- */
html {
font-size: 16px;
font-family: var(--font);
line-height: var(--line-height);
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
font-weight: var(--x-bold);
line-height: var(--line-height-heading);
letter-spacing: -0.005em;
}
h2 {
margin: 0 0 0.25em 0;
font-size: 1.5rem;
}
h3 {
margin: 1em 0 0.25em 0;
font-size: 1.06rem;
}
code {
font-family: var(--code-font);
overflow-wrap: break-word;
}
/* -- Layout ------------------------------------------------------------------ */
.image-section {
border-bottom: 1px solid #ccc;
padding: 16px 16px 32px 16px;
margin-bottom: 32px;
}
.swatch {
display: inline-block;
background: #dddddd;
}
.color .swatch {
width: 6rem;
height: 3rem;
}
.palette .swatch {
width: 3rem;
height: 2rem;
}
.time {
color: var(--muted-color);
font-weight: normal;
}
================================================
FILE: cypress.config.cjs
================================================
const { defineConfig } = require('cypress')
module.exports = defineConfig({
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.cjs')(on, config)
},
experimentalRunAllSpecs: true,
},
})
================================================
FILE: examples/css/screen.css
================================================
:root {
/* Colors */
--color: #000;
--bg-color: #f9f9f9;
--primary-color: #fc4c02;
--secondary-color: #f68727;
--muted-color: #999;
--code-color: var(--primary-color);
--code-bg-color: #fff;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--code-font: Menlo, Consolas, Monaco, Lucida Console, monospace;
--bold: 700;
--x-bold: 900;
--line-height: 1.5em;
--line-height-heading: 1.3em;
/* Breakpoints */
--sm-screen: 640px;
}
/* Base
* *----------------------------------------------- */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background: var(--bg-color);
}
/* Typography
* *----------------------------------------------- */
html {
font-size: 16px;
font-family: var(--font);
line-height: var(--line-height);
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
font-weight: var(--x-bold);
line-height: var(--line-height-heading);
letter-spacing: -0.005em;
}
h2 {
margin: 0 0 0.25em 0;
font-size: 1.5rem;
}
h3 {
margin: 1em 0 0.25em 0;
font-size: 1.06rem;
}
code {
font-family: var(--code-font);
overflow-wrap: break-word;
}
/* -- Layout ------------------------------------------------------------------ */
.image-section {
border-bottom: 1px solid #ccc;
padding: 16px 16px 32px 16px;
margin-bottom: 32px;
}
.swatch {
display: inline-block;
background: #dddddd;
border-radius: 8px;
}
.color .swatch {
width: 6rem;
height: 3rem;
}
.palette .swatch {
width: 3rem;
height: 2rem;
}
.time {
color: var(--muted-color);
font-weight: normal;
}
================================================
FILE: examples/js/demo.js
================================================
var colorThief = new ColorThief();
var images = [
'image-1.jpg',
'image-2.jpg',
'image-3.jpg',
];
// Render example images
var examplesHTML = Mustache.to_html(document.getElementById('image-tpl').innerHTML, images);
document.getElementById('example-images').innerHTML = examplesHTML;
// Once images are loaded, process them
document.querySelectorAll('.image').forEach((image) => {
const section = image.closest('.image-section');
if (image.complete) {
showColorsForImage(image, section);
} else {
image.addEventListener('load', function() {
showColorsForImage(image, section);
});
}
})
// Run Color Thief functions and display results below image.
// We also log execution time of functions for display.
const showColorsForImage = function(image, section) {
let start = Date.now();
// 🎨🔓
let result = colorThief.getColor(image);
let elapsedTime = Date.now() - start;
const colorHTML = Mustache.to_html(document.getElementById('color-tpl').innerHTML, {
color: result,
colorStr: result.toString(),
elapsedTime
})
// getPalette(img)
let paletteHTML = '';
let colorCounts = [3, 9];
colorCounts.forEach((count) => {
let start = Date.now();
// 🎨🔓
let result = colorThief.getPalette(image, count);
let elapsedTime = Date.now() - start;
paletteHTML += Mustache.to_html(document.getElementById('palette-tpl').innerHTML, {
count,
palette: result,
paletteStr: result.toString(),
elapsedTime
})
});
const outputEl = section.querySelector('.output');
outputEl.innerHTML += colorHTML + paletteHTML;
};
================================================
FILE: index.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Color Thief v3 — Examples</title>
<style>
/* ------------------------------------------------------------------ */
/* Variables */
/* ------------------------------------------------------------------ */
:root {
--sans: 'Helvetica Neue', Helvetica, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, 'Liberation Mono', monospace;
--text: #111;
--text-2: #444;
--text-3: #888;
--bg: #fff;
--surface: #f6f6f6;
--border: #e0e0e0;
--radius: 8px;
--container: 860px;
}
/* ------------------------------------------------------------------ */
/* Reset & base */
/* ------------------------------------------------------------------ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--sans);
color: var(--text);
background: var(--bg);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img { display: block; max-width: 100%; }
/* ------------------------------------------------------------------ */
/* Typography */
/* ------------------------------------------------------------------ */
h1 {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.15;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.015em;
font-family: var(--mono);
}
h3 {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
}
p { color: var(--text-2); }
code, pre { font-family: var(--mono); }
/* ------------------------------------------------------------------ */
/* Layout */
/* ------------------------------------------------------------------ */
.container {
max-width: var(--container);
margin: 0 auto;
padding: 0 24px;
}
/* ------------------------------------------------------------------ */
/* Header */
/* ------------------------------------------------------------------ */
.header {
padding: 80px 0 60px;
border-bottom: 1px solid var(--border);
}
.header p {
margin-top: 8px;
font-size: 1.1rem;
}
.version {
display: inline-block;
font-family: var(--mono);
font-size: 0.5em;
font-weight: 500;
vertical-align: super;
color: var(--text-3);
margin-left: 2px;
}
/* ------------------------------------------------------------------ */
/* Sections */
/* ------------------------------------------------------------------ */
.section {
padding: 56px 0;
border-bottom: 1px solid var(--border);
}
.section:last-child { border-bottom: none; }
.section-num {
display: inline-block;
font-family: var(--mono);
font-size: 0.75rem;
font-weight: 500;
color: var(--text-3);
background: var(--surface);
padding: 2px 10px;
border-radius: 99px;
margin-bottom: 12px;
}
.section h2 { margin-bottom: 6px; }
.section > p {
margin-bottom: 20px;
max-width: 600px;
}
/* ------------------------------------------------------------------ */
/* Code blocks */
/* ------------------------------------------------------------------ */
.code-block {
background: var(--surface);
border-left: 3px solid var(--border);
border-radius: 0 var(--radius) var(--radius) 0;
padding: 16px 20px;
margin-bottom: 28px;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.65;
color: var(--text-2);
}
.code-block code {
white-space: pre;
}
/* ------------------------------------------------------------------ */
/* Output areas */
/* ------------------------------------------------------------------ */
.output {
opacity: 0;
transform: translateY(6px);
transition: opacity 0.4s ease, transform 0.4s ease;
}
.output.visible {
opacity: 1;
transform: translateY(0);
}
/* ------------------------------------------------------------------ */
/* Demo images */
/* ------------------------------------------------------------------ */
.demo-img {
width: 100%;
max-width: 240px;
border-radius: var(--radius);
object-fit: cover;
}
.demo-img-sm {
width: 100%;
max-width: 180px;
border-radius: var(--radius);
object-fit: cover;
}
.source-img {
width: 100%;
max-width: 240px;
border-radius: var(--radius);
margin-bottom: 20px;
}
/* ------------------------------------------------------------------ */
/* Swatches */
/* ------------------------------------------------------------------ */
.swatch {
display: inline-block;
border-radius: 6px;
position: relative;
cursor: default;
transition: transform 0.15s ease;
}
.swatch:hover { transform: scale(1.08); }
.swatch-lg {
width: 64px;
height: 40px;
}
.swatch-md {
width: 48px;
height: 32px;
}
.swatch-sm {
width: 36px;
height: 24px;
}
.swatch[data-hex]::after {
content: attr(data-hex);
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
font-family: var(--mono);
font-size: 0.65rem;
color: var(--text-3);
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.swatch:hover[data-hex]::after { opacity: 1; }
.swatch-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-bottom: 20px;
}
/* ------------------------------------------------------------------ */
/* Dominant color cards */
/* ------------------------------------------------------------------ */
.dominant-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 24px;
}
.dominant-card { display: flex; flex-direction: column; gap: 12px; }
.dominant-result {
display: flex;
align-items: center;
gap: 12px;
}
.dominant-meta {
font-family: var(--mono);
font-size: 0.8rem;
color: var(--text-2);
line-height: 1.7;
}
.dominant-meta .hex { font-weight: 600; color: var(--text); }
.timing {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-3);
}
/* ------------------------------------------------------------------ */
/* Property table */
/* ------------------------------------------------------------------ */
.prop-table {
width: 100%;
max-width: 560px;
border-collapse: collapse;
font-size: 0.85rem;
}
.prop-table th {
text-align: left;
font-weight: 500;
font-family: var(--mono);
color: var(--text-3);
padding: 8px 16px 8px 0;
border-bottom: 1px solid var(--border);
white-space: nowrap;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.prop-table td {
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.prop-table td:first-child {
font-family: var(--mono);
font-weight: 500;
padding-right: 24px;
white-space: nowrap;
color: var(--text-2);
}
.prop-table td:last-child {
font-family: var(--mono);
font-size: 0.85rem;
}
.prop-table tr:last-child td { border-bottom: none; }
.prop-swatch {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 4px;
vertical-align: middle;
margin-right: 6px;
}
/* ------------------------------------------------------------------ */
/* Swatch role cards */
/* ------------------------------------------------------------------ */
.swatch-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.swatch-card {
border-radius: var(--radius);
padding: 20px 16px;
min-height: 100px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.swatch-card-empty {
background: var(--surface);
border: 2px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
}
.swatch-card .role {
font-family: var(--mono);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.swatch-card .hex-label {
font-family: var(--mono);
font-size: 0.8rem;
margin-top: 8px;
}
.swatch-card-empty .role {
font-size: 0.7rem;
color: var(--text-3);
}
/* ------------------------------------------------------------------ */
/* Comparison layouts */
/* ------------------------------------------------------------------ */
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
@media (max-width: 600px) {
.comparison { grid-template-columns: 1fr; }
}
.comparison-col h3 { margin-bottom: 12px; }
/* ------------------------------------------------------------------ */
/* Quality rows */
/* ------------------------------------------------------------------ */
.quality-row {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
}
.quality-row:not(:last-child) {
border-bottom: 1px solid #f0f0f0;
}
.quality-label {
font-family: var(--mono);
font-size: 0.8rem;
font-weight: 500;
min-width: 100px;
color: var(--text-2);
}
.quality-swatches { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; }
/* ------------------------------------------------------------------ */
/* Async comparison */
/* ------------------------------------------------------------------ */
.async-row {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid #f0f0f0;
}
.async-row:last-child { border-bottom: none; }
.async-label {
font-family: var(--mono);
font-size: 0.8rem;
font-weight: 500;
min-width: 140px;
color: var(--text-2);
}
.async-swatches { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; }
/* ------------------------------------------------------------------ */
/* Progressive */
/* ------------------------------------------------------------------ */
.progress-track {
height: 6px;
background: var(--surface);
border-radius: 99px;
overflow: hidden;
margin-bottom: 16px;
}
.progress-fill {
height: 100%;
width: 0;
border-radius: 99px;
background: var(--text);
transition: width 0.3s ease, background 0.3s ease;
}
.progress-label {
font-family: var(--mono);
font-size: 0.8rem;
color: var(--text-3);
margin-bottom: 6px;
}
.progressive-stage {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.progressive-stage:last-child { border-bottom: none; }
.progressive-stage .stage-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.stage-badge {
font-family: var(--mono);
font-size: 0.7rem;
font-weight: 500;
background: var(--surface);
padding: 2px 8px;
border-radius: 99px;
color: var(--text-3);
}
.stage-badge.final {
background: var(--text);
color: #fff;
}
/* ------------------------------------------------------------------ */
/* Error box */
/* ------------------------------------------------------------------ */
.error-box {
font-family: var(--mono);
font-size: 0.85rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 16px 20px;
border-radius: var(--radius);
line-height: 1.6;
}
.error-box strong {
display: block;
margin-bottom: 4px;
}
/* ------------------------------------------------------------------ */
/* Create-color demo */
/* ------------------------------------------------------------------ */
.color-preview {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.color-preview-swatch {
width: 80px;
height: 80px;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 0.75rem;
font-weight: 600;
}
.color-preview-hex {
font-family: var(--mono);
font-size: 1.5rem;
font-weight: 600;
}
/* ------------------------------------------------------------------ */
/* Footer */
/* ------------------------------------------------------------------ */
.footer {
padding: 40px 0 60px;
text-align: center;
}
.footer a {
color: var(--text-3);
text-decoration: none;
font-family: var(--mono);
font-size: 0.85rem;
border-bottom: 1px solid var(--border);
padding-bottom: 1px;
transition: color 0.15s, border-color 0.15s;
}
.footer a:hover { color: var(--text); border-color: var(--text); }
/* ------------------------------------------------------------------ */
/* Responsive */
/* ------------------------------------------------------------------ */
@media (max-width: 600px) {
h1 { font-size: 1.75rem; }
.header { padding: 48px 0 40px; }
.section { padding: 40px 0; }
.dominant-grid { grid-template-columns: 1fr; }
.swatch-cards { grid-template-columns: repeat(2, 1fr); }
}
/* ------------------------------------------------------------------ */
/* Observe / video glow */
/* ------------------------------------------------------------------ */
.video-glow-wrap {
position: relative;
border-radius: var(--radius);
max-width: 320px;
margin-bottom: 36px;
}
.video-glow-wrap::before {
content: '';
position: absolute;
left: -20%;
top: -20%;
width: 110%;
height: 110%;
inset: 4px;
border-radius: inherit;
background: var(--glow-color, transparent);
filter: blur(36px) saturate(1.8);
opacity: 1;
z-index: 0;
transition: background 1s ease;
}
.video-glow-wrap video {
position: relative;
display: block;
width: 100%;
border-radius: inherit;
z-index: 1;
cursor: pointer;
}
.video-play-btn {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
transition: opacity 0.25s ease;
}
.video-play-btn.hidden {
opacity: 0;
pointer-events: none;
}
.observe-dominant {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
min-height: 48px;
}
.observe-dominant-swatch {
width: 48px;
height: 48px;
border-radius: var(--radius);
flex-shrink: 0;
}
.observe-dominant-meta {
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.5;
}
.observe-label {
font-family: var(--mono);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
margin-bottom: 6px;
}
</style>
</head>
<body>
<!-- ================================================================== -->
<!-- Header -->
<!-- ================================================================== -->
<header class="header">
<div class="container">
<h1>Color Thief <span class="version">v3</span></h1>
<p>Extract dominant colors and palettes from images. Every example on this page runs live.</p>
</div>
</header>
<main class="container">
<!-- ============================================================== -->
<!-- 01. Loading the Library -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">01</span>
<h2>Loading the Library</h2>
<p>Color Thief works in browsers and Node.js. Pick the method that fits your setup.</p>
<h3>Install</h3>
<div class="code-block"><code>npm install colorthief</code></div>
<h3>ESM (bundlers & Node.js)</h3>
<div class="code-block"><code>import { getColorSync, getPaletteSync } from 'colorthief';</code></div>
<h3>CommonJS (Node.js)</h3>
<div class="code-block"><code>const { getColor, getPalette } = require('colorthief');</code></div>
<h3>Script tag (no build step)</h3>
<div class="code-block"><code><script src="https://unpkg.com/colorthief@3/dist/umd/color-thief.global.js"></script>
<script>
const color = ColorThief.getColorSync(img);
</script></code></div>
<h3>ES module in the browser (no bundler)</h3>
<div class="code-block"><code><script type="module">
import { getColorSync } from 'https://unpkg.com/colorthief@3/dist/index.js';
</script></code></div>
</section>
<!-- ============================================================== -->
<!-- 02. getColorSync -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">02</span>
<h2>getColorSync()</h2>
<p>The simplest way to use Color Thief. Extract the single dominant color from an image, synchronously.</p>
<div class="code-block"><code>const color = getColorSync(img);
color.hex(); // '#e84393'
color.textColor; // '#000000'</code></div>
<div class="output" id="out-dominant">
<div class="dominant-grid">
<div class="dominant-card">
<img class="demo-img" id="img1" src="examples/img/image-1.jpg" alt="Example 1">
<div class="dominant-result" id="dom-result-1"></div>
</div>
<div class="dominant-card">
<img class="demo-img" id="img2" src="examples/img/image-2.jpg" alt="Example 2">
<div class="dominant-result" id="dom-result-2"></div>
</div>
<div class="dominant-card">
<img class="demo-img" id="img3" src="examples/img/image-3.jpg" alt="Example 3">
<div class="dominant-result" id="dom-result-3"></div>
</div>
</div>
</div>
</section>
<!-- ============================================================== -->
<!-- 03. getPaletteSync -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">03</span>
<h2>getPaletteSync()</h2>
<p>Extract a multi-color palette. Each color in the palette is a full Color object.</p>
<div class="code-block"><code>const palette = getPaletteSync(img, { colorCount: 8 });
palette.forEach(c => console.log(c.hex()));</code></div>
<div class="output" id="out-palette">
<img class="source-img" src="examples/img/image-1.jpg" alt="Source image">
<div class="swatch-row" id="palette-swatches"></div>
<div class="timing" id="palette-timing"></div>
</div>
</section>
<!-- ============================================================== -->
<!-- 04. Color Object -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">04</span>
<h2>Color Object</h2>
<p>Every extracted color is a rich object with format conversions, accessibility metadata, and contrast ratios.</p>
<div class="code-block"><code>const color = getColorSync(img);
color.rgb() // { r, g, b }
color.hex() // '#rrggbb'
color.hsl() // { h, s, l }
color.oklch() // { l, c, h }
color.css() // 'rgb(255, 128, 0)' — also 'hsl' and 'oklch'
color.array() // [r, g, b]
color.toString() // '#rrggbb' — works in template literals
color.textColor // '#ffffff' or '#000000'
color.isDark // true/false
color.isLight // true/false
color.contrast // { white, black, foreground }
color.population // raw pixel count
color.proportion // 0–1 share of total</code></div>
<div class="output" id="out-color-obj">
<img class="source-img" src="examples/img/image-1.jpg" alt="Source image">
<div class="color-preview" id="color-preview"></div>
<table class="prop-table" id="prop-table"></table>
</div>
</section>
<!-- ============================================================== -->
<!-- 05. getSwatchesSync -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">05</span>
<h2>getSwatchesSync()</h2>
<p>Classify palette colors into six semantic roles: Vibrant, Muted, DarkVibrant, DarkMuted, LightVibrant, LightMuted. Each swatch includes text color recommendations.</p>
<div class="code-block"><code>const swatches = getSwatchesSync(img);
swatches.Vibrant?.color.hex(); // '#e84393'
swatches.DarkMuted?.titleTextColor.hex(); // '#ffffff'</code></div>
<div class="output" id="out-swatches">
<img class="source-img" src="examples/img/image-2.jpg" alt="Source image">
<div class="swatch-cards" id="swatch-cards"></div>
</div>
</section>
<!-- ============================================================== -->
<!-- 06. OKLCH vs RGB -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">06</span>
<h2>OKLCH vs RGB Quantization</h2>
<p>OKLCH quantization produces more perceptually uniform palettes. Colors that "feel" evenly spaced to the human eye.</p>
<div class="code-block"><code>// Default — quantize in sRGB
const rgb = getPaletteSync(img, { colorCount: 8 });
// Perceptual — quantize in OKLCH
const oklch = getPaletteSync(img, { colorCount: 8, colorSpace: 'oklch' });</code></div>
<div class="output" id="out-oklch">
<img class="source-img" src="examples/img/image-3.jpg" alt="Source image">
<div class="comparison">
<div class="comparison-col">
<h3>RGB (default)</h3>
<div class="swatch-row" id="oklch-rgb"></div>
</div>
<div class="comparison-col">
<h3>OKLCH</h3>
<div class="swatch-row" id="oklch-oklch"></div>
</div>
</div>
</div>
</section>
<!-- ============================================================== -->
<!-- 07. Quality comparison -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">07</span>
<h2>Quality Settings</h2>
<p>The <code>quality</code> option controls how many pixels are sampled. Lower values sample more pixels (slower, more accurate). Default is 10.</p>
<div class="code-block"><code>getPaletteSync(img, { quality: 1 }); // Every pixel
getPaletteSync(img, { quality: 10 }); // Every 10th pixel (default)
getPaletteSync(img, { quality: 50 }); // Every 50th pixel</code></div>
<div class="output" id="out-quality">
<img class="source-img" src="examples/img/image-1.jpg" alt="Source image">
</div>
</section>
<!-- ============================================================== -->
<!-- 08. Async API & Workers -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">08</span>
<h2>Async API & Web Workers</h2>
<p>The async API works on both browser and Node.js. With <code>worker: true</code>, quantization runs off the main thread.</p>
<div class="code-block"><code>// Async (works in browser and Node.js)
const palette = await getPalette(img, { colorCount: 6 });
// Offload to Web Worker (browser only)
const palette = await getPalette(img, { colorCount: 6, worker: true });</code></div>
<div class="output" id="out-async">
<img class="source-img" src="examples/img/image-1.jpg" alt="Source image">
</div>
</section>
<!-- ============================================================== -->
<!-- 09. Progressive extraction -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">09</span>
<h2>getPaletteProgressive()</h2>
<p>Progressively extract a palette in 3 passes. Show a rough result instantly, then refine it. Useful for large images where full extraction takes time.</p>
<div class="code-block"><code>for await (const { palette, progress, done } of getPaletteProgressive(img)) {
updateUI(palette, progress);
// progress: 0.06 → 0.25 → 1.0
}</code></div>
<div class="output" id="out-progressive">
<img class="source-img" src="examples/img/image-2.jpg" alt="Source image">
<div class="progress-track">
<div class="progress-fill" id="prog-fill"></div>
</div>
<div id="prog-stages"></div>
</div>
</section>
<!-- ============================================================== -->
<!-- 10. AbortController -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">10</span>
<h2>AbortController</h2>
<p>Cancel in-flight extractions with a standard AbortSignal. Useful when the user navigates away before extraction completes.</p>
<div class="code-block"><code>const controller = new AbortController();
controller.abort(); // cancel immediately
try {
await getColor(img, { signal: controller.signal });
} catch (e) {
console.log(e.name); // 'AbortError'
}</code></div>
<div class="output" id="out-abort">
<img class="source-img" src="examples/img/image-1.jpg" alt="Source image">
</div>
</section>
<!-- ============================================================== -->
<!-- 11. createColor -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">11</span>
<h2>createColor()</h2>
<p>Build a Color object manually from RGB values. Useful for creating colors programmatically or working with known values.</p>
<div class="code-block"><code>const color = createColor(232, 67, 147, 1);
color.hex(); // '#e84393'
color.isDark; // false
color.textColor; // '#000000'
`${color}`; // '#e84393' — toString() returns hex</code></div>
<div class="output" id="out-create">
<div class="color-preview" id="create-preview"></div>
<table class="prop-table" id="create-table"></table>
</div>
</section>
<!-- ============================================================== -->
<!-- 12. observe — Live Extraction -->
<!-- ============================================================== -->
<section class="section">
<span class="section-num">12</span>
<h2>observe()</h2>
<p>Reactively watch a source and get palette updates whenever it changes. Works with <code><video></code>, <code><canvas></code>, and <code><img></code> elements. For video, extraction runs on each animation frame (throttled) while playing, and on seek. For canvas, it polls via rAF. For images, it uses a MutationObserver to detect <code>src</code> changes.</p>
<div class="code-block"><code>const controller = observe(videoElement, {
throttle: 200, // ms between updates
colorCount: 5,
onChange(palette) {
const dominant = palette[0];
document.body.style.background = dominant.css();
},
});
controller.stop(); // clean up</code></div>
<div class="output" id="out-observe" style="margin-top:48px">
<div>
<div class="video-glow-wrap" id="video-glow-wrap">
<video id="observe-video" src="examples/video/colors.mp4" muted loop playsinline></video>
<button class="video-play-btn" id="video-play-btn" aria-label="Play video">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="24" fill="rgba(0,0,0,0.5)"/><polygon points="19,14 19,34 36,24" fill="#fff"/></svg>
</button>
</div>
</div>
<div>
<div class="observe-label">Dominant</div>
<div class="observe-dominant" id="observe-dominant"></div>
</div>
</div>
</section>
</main>
<footer class="footer">
<a href="https://github.com/lokesh/color-thief">github.com/lokesh/color-thief</a>
</footer>
<!-- ================================================================== -->
<!-- Library + Demo Script -->
<!-- ================================================================== -->
<script src="dist/umd/color-thief.global.js"></script>
<script>
(async () => {
const {
getColorSync,
getPaletteSync,
getSwatchesSync,
getColor,
getPalette,
getPaletteProgressive,
createColor,
observe,
} = ColorThief;
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function waitForImage(img) {
return new Promise((resolve, reject) => {
if (img.complete && img.naturalWidth) resolve(img);
else {
img.addEventListener('load', () => resolve(img));
img.addEventListener('error', reject);
}
});
}
function timed(fn) {
const t0 = performance.now();
const result = fn();
return { result, ms: (performance.now() - t0).toFixed(1) };
}
async function timedAsync(fn) {
const t0 = performance.now();
const result = await fn();
return { result, ms: (performance.now() - t0).toFixed(1) };
}
function show(id) {
document.getElementById(id).classList.add('visible');
}
function swatchHTML(color, size = 'md') {
return `<div class="swatch swatch-${size}" style="background:${color.hex()}" data-hex="${color.hex()}"></div>`;
}
function renderColorTable(color, tableId) {
const { r, g, b } = color.rgb();
const hsl = color.hsl();
const oklch = color.oklch();
const rows = [
['.rgb()', `{ r: ${r}, g: ${g}, b: ${b} }`],
['.hex()', color.hex()],
['.hsl()', `{ h: ${hsl.h}, s: ${hsl.s}, l: ${hsl.l} }`],
['.oklch()', `{ l: ${oklch.l.toFixed(3)}, c: ${oklch.c.toFixed(3)}, h: ${oklch.h.toFixed(1)} }`],
[".css('rgb')", color.css('rgb')],
[".css('hsl')", color.css('hsl')],
[".css('oklch')", color.css('oklch')],
['.array()', `[${color.array().join(', ')}]`],
['.toString()', `"${color.toString()}"`],
['.textColor', `<span class="prop-swatch" style="background:${color.textColor}"></span>${color.textColor}`],
['.isDark', String(color.isDark)],
['.isLight', String(color.isLight)],
['.population', String(color.population)],
['.proportion', color.proportion.toFixed(4)],
['.contrast.white', color.contrast.white.toFixed(2)],
['.contrast.black', color.contrast.black.toFixed(2)],
['.contrast.foreground', `<span class="prop-swatch" style="background:${color.contrast.foreground.hex()}"></span>${color.contrast.foreground.hex()}`],
];
document.getElementById(tableId).innerHTML =
'<thead><tr><th>Property</th><th>Value</th></tr></thead><tbody>' +
rows.map(([prop, val]) => `<tr><td>${prop}</td><td>${val}</td></tr>`).join('') +
'</tbody>';
}
// ------------------------------------------------------------------
// Wait for images
// ------------------------------------------------------------------
const img1 = document.getElementById('img1');
const img2 = document.getElementById('img2');
const img3 = document.getElementById('img3');
await Promise.all([waitForImage(img1), waitForImage(img2), waitForImage(img3)]);
// ------------------------------------------------------------------
// 02. getColorSync — Dominant Color
// ------------------------------------------------------------------
{
[
[img1, 'dom-result-1'],
[img2, 'dom-result-2'],
[img3, 'dom-result-3'],
].forEach(([img, id]) => {
const { result: color, ms } = timed(() => getColorSync(img));
if (!color) return;
const { r, g, b } = color.rgb();
document.getElementById(id).innerHTML =
swatchHTML(color, 'lg') +
`<div class="dominant-meta">
<span class="hex">${color.hex()}</span><br>
rgb(${r}, ${g}, ${b})<br>
<span class="timing">${ms}ms</span>
</div>`;
});
show('out-dominant');
}
// ------------------------------------------------------------------
// 03. getPaletteSync — Palette
// ------------------------------------------------------------------
{
const { result: palette, ms } = timed(() => getPaletteSync(img1, { colorCount: 8 }));
if (palette) {
document.getElementById('palette-swatches').innerHTML =
palette.map(c => swatchHTML(c, 'lg')).join('');
document.getElementById('palette-timing').textContent = `${ms}ms`;
}
show('out-palette');
}
// ------------------------------------------------------------------
// 04. Color Object
// ------------------------------------------------------------------
{
const color = getColorSync(img1);
if (color) {
document.getElementById('color-preview').innerHTML =
`<div class="color-preview-swatch" style="background:${color.hex()};color:${color.textColor}">Aa</div>
<div class="color-preview-hex">${color.hex()}</div>`;
renderColorTable(color, 'prop-table');
}
show('out-color-obj');
}
// ------------------------------------------------------------------
// 05. Semantic Swatches
// ------------------------------------------------------------------
{
const { result: swatches, ms } = timed(() => getSwatchesSync(img2));
const roles = ['Vibrant', 'Muted', 'DarkVibrant', 'DarkMuted', 'LightVibrant', 'LightMuted'];
document.getElementById('swatch-cards').innerHTML = roles.map(role => {
const s = swatches[role];
if (!s) {
return `<div class="swatch-card swatch-card-empty"><span class="role">${role}</span></div>`;
}
return `<div class="swatch-card" style="background:${s.color.hex()}">
<span class="role" style="color:${s.titleTextColor.hex()}">${role}</span>
<span class="hex-label" style="color:${s.bodyTextColor.hex()}">${s.color.hex()}</span>
</div>`;
}).join('');
show('out-swatches');
}
// ------------------------------------------------------------------
// 06. OKLCH vs RGB
// ------------------------------------------------------------------
{
const rgb = getPaletteSync(img3, { colorCount: 8 });
const oklch = getPaletteSync(img3, { colorCount: 8, colorSpace: 'oklch' });
if (rgb) {
document.getElementById('oklch-rgb').innerHTML = rgb.map(c => swatchHTML(c, 'lg')).join('');
}
if (oklch) {
document.getElementById('oklch-oklch').innerHTML = oklch.map(c => swatchHTML(c, 'lg')).join('');
}
show('out-oklch');
}
// ------------------------------------------------------------------
// 07. Quality
// ------------------------------------------------------------------
{
const quals = [1, 10, 50];
const container = document.getElementById('out-quality');
container.insertAdjacentHTML('beforeend', quals.map(q => {
const { result: pal, ms } = timed(() => getPaletteSync(img1, { colorCount: 6, quality: q }));
return `<div class="quality-row">
<div class="quality-label">quality: ${q} <span class="timing">${ms}ms</span></div>
<div class="quality-swatches">${pal ? pal.map(c => swatchHTML(c, 'md')).join('') : ''}</div>
</div>`;
}).join(''));
show('out-quality');
}
// ------------------------------------------------------------------
// 08. Async + Workers
// ------------------------------------------------------------------
{
const container = document.getElementById('out-async');
const rows = [];
// Sync
const sync = timed(() => getPaletteSync(img1, { colorCount: 6 }));
rows.push({
label: 'Sync',
palette: sync.result,
ms: sync.ms,
});
// Async
const async_ = await timedAsync(() => getPalette(img1, { colorCount: 6 }));
rows.push({
label: 'Async',
palette: async_.result,
ms: async_.ms,
});
// Worker
try {
const worker = await timedAsync(() => getPalette(img1, { colorCount: 6, worker: true }));
rows.push({
label: 'Async + Worker',
palette: worker.result,
ms: worker.ms,
});
} catch (e) {
rows.push({
label: 'Async + Worker',
palette: null,
ms: 'unsupported',
});
}
container.insertAdjacentHTML('beforeend', rows.map(({ label, palette, ms }) =>
`<div class="async-row">
<div class="async-label">${label} <span class="timing">${ms}ms</span></div>
<div class="async-swatches">${palette ? palette.map(c => swatchHTML(c, 'sm')).join('') : '<span class="timing">Not available</span>'}</div>
</div>`
).join(''));
show('out-async');
}
// ------------------------------------------------------------------
// 09. Progressive
// ------------------------------------------------------------------
{
const fill = document.getElementById('prog-fill');
const stages = document.getElementById('prog-stages');
let stageIdx = 0;
const labels = ['Rough (16x skip)', 'Medium (4x skip)', 'Final (full quality)'];
for await (const { palette, progress, done } of getPaletteProgressive(img2, { colorCount: 6 })) {
fill.style.width = `${progress * 100}%`;
if (done) fill.style.background = '#16a34a';
const badgeClass = done ? 'stage-badge final' : 'stage-badge';
const html = `<div class="progressive-stage">
<div class="stage-header">
<span class="${badgeClass}">${labels[stageIdx]}</span>
<span class="timing">${(progress * 100).toFixed(0)}%</span>
</div>
<div class="swatch-row">${palette.map(c => swatchHTML(c, 'md')).join('')}</div>
</div>`;
stages.innerHTML += html;
stageIdx++;
}
show('out-progressive');
}
// ------------------------------------------------------------------
// 10. AbortController
// ------------------------------------------------------------------
{
const controller = new AbortController();
controller.abort();
try {
await getColor(img1, { signal: controller.signal });
document.getElementById('out-abort').insertAdjacentHTML('beforeend',
'<div class="error-box">Expected an error, but the call succeeded.</div>');
} catch (e) {
document.getElementById('out-abort').insertAdjacentHTML('beforeend',
`<div class="error-box">
<strong>Caught error:</strong>
${e.name || 'Error'}: ${e.message || 'The operation was aborted.'}
</div>`);
}
show('out-abort');
}
// ------------------------------------------------------------------
// 11. createColor
// ------------------------------------------------------------------
{
const color = createColor(232, 67, 147, 1);
document.getElementById('create-preview').innerHTML =
`<div class="color-preview-swatch" style="background:${color.hex()};color:${color.textColor}">Aa</div>
<div class="color-preview-hex">${color.hex()}</div>`;
renderColorTable(color, 'create-table');
show('out-create');
}
// ------------------------------------------------------------------
// 12. observe — Live Extraction
// ------------------------------------------------------------------
{
const video = document.getElementById('observe-video');
const glowWrap = document.getElementById('video-glow-wrap');
const playBtn = document.getElementById('video-play-btn');
const dominantEl = document.getElementById('observe-dominant');
// Wait for the video to have enough data
await new Promise((resolve) => {
if (video.readyState >= 2) return resolve();
video.addEventListener('loadeddata', resolve, { once: true });
});
// Start observing — dominant color updates live while video plays
const controller = observe(video, {
throttle: 200,
colorCount: 5,
onChange(palette) {
const dominant = palette[0];
// Update backlit glow
glowWrap.style.setProperty('--glow-color', dominant.css());
// Dominant color display
dominantEl.innerHTML =
`<div class="observe-dominant-swatch" style="background:${dominant.hex()}"></div>` +
`<div class="observe-dominant-meta">` +
`<strong style="color:${dominant.hex()}">${dominant.hex()}</strong>` +
`</div>`;
},
});
// Toggle play/pause on click
function togglePlay() {
if (video.paused) {
video.play();
playBtn.classList.add('hidden');
} else {
video.pause();
playBtn.classList.remove('hidden');
}
}
playBtn.addEventListener('click', togglePlay);
video.addEventListener('click', togglePlay);
show('out-observe');
}
})();
</script>
</body>
</html>
================================================
FILE: package.json
================================================
{
"name": "colorthief",
"version": "3.3.1",
"type": "module",
"author": {
"name": "Lokesh Dhakar",
"email": "lokesh.dhakar@gmail.com",
"url": "http://lokeshdhakar.com/"
},
"description": "Extract dominant colors and palettes from images — TypeScript, OKLCH, semantic swatches, live video extraction.",
"keywords": [
"color",
"palette",
"sampling",
"image",
"picture",
"photo",
"canvas",
"oklch",
"swatch"
],
"homepage": "http://lokeshdhakar.com/projects/color-thief/",
"repository": {
"type": "git",
"url": "git+https://github.com/lokesh/color-thief.git"
},
"license": "MIT",
"bin": {
"colorthief": "dist/cli.js"
},
"files": [
"dist/",
"src/"
],
"exports": {
".": {
"browser": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/index.browser.js"
},
"require": {
"types": "./dist/types/index.d.cts",
"default": "./dist/index.browser.cjs"
}
},
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/types/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./internals": {
"browser": {
"import": {
"types": "./dist/types/internals.browser.d.ts",
"default": "./dist/internals.browser.js"
},
"require": {
"types": "./dist/types/internals.browser.d.cts",
"default": "./dist/internals.browser.cjs"
}
},
"import": {
"types": "./dist/types/internals.d.ts",
"default": "./dist/internals.js"
},
"require": {
"types": "./dist/types/internals.d.cts",
"default": "./dist/internals.cjs"
}
},
"./cli": {
"import": "./dist/cli.js"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/types/index.d.ts",
"scripts": {
"prepublishOnly": "npm run build",
"build": "tsup",
"watch": "tsup --watch",
"dev": "http-server",
"test": "mocha && cypress run --config video=false",
"test:node": "mocha",
"test:browser": "cypress run --headed --browser chrome",
"cypress": "cypress open",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^20.11.0",
"chai": "^4.2.0",
"chai-as-promised": "^7.1.1",
"cypress": "^13.15.0",
"http-server": "^14.1.1",
"mocha": "^10.2.0",
"mustache": "^3.0.1",
"sharp": "^0.34.5",
"tsup": "^8.0.0",
"typescript": "^5.3.0"
},
"peerDependencies": {
"sharp": ">=0.33.0"
},
"peerDependenciesMeta": {
"sharp": {
"optional": true
}
}
}
================================================
FILE: src/api.ts
================================================
import type {
Color,
ExtractionOptions,
ImageSource,
PixelData,
PixelLoader,
ProgressiveResult,
Quantizer,
SwatchMap,
} from './types.js';
import { validateOptions, extractPalette } from './pipeline.js';
import { extractProgressive } from './progressive.js';
import { classifySwatches } from './swatches.js';
import { MmcqQuantizer } from './quantizers/mmcq.js';
import { resolveDefaultLoader } from './resolve-loader.js';
// ---------------------------------------------------------------------------
// Global configuration
// ---------------------------------------------------------------------------
let globalLoader: PixelLoader<ImageSource> | null = null;
let globalQuantizer: Quantizer | null = null;
/**
* Override the default pixel loader and/or quantizer.
*
* ```ts
* import { configure } from 'colorthief';
* import { WasmQuantizer } from 'colorthief/internals';
* const q = new WasmQuantizer();
* await q.init();
* configure({ quantizer: q });
* ```
*/
export function configure(opts: {
loader?: PixelLoader<ImageSource>;
quantizer?: Quantizer;
}): void {
if (opts.loader) globalLoader = opts.loader;
if (opts.quantizer) globalQuantizer = opts.quantizer;
}
// ---------------------------------------------------------------------------
// Lazy environment detection
// ---------------------------------------------------------------------------
async function getLoader(perCall?: PixelLoader<ImageSource>): Promise<PixelLoader<ImageSource>> {
if (perCall) return perCall;
if (globalLoader) return globalLoader;
globalLoader = await resolveDefaultLoader();
return globalLoader;
}
async function getQuantizer(perCall?: Quantizer): Promise<Quantizer> {
if (perCall) {
await perCall.init();
return perCall;
}
if (globalQuantizer) return globalQuantizer;
const q = new MmcqQuantizer();
await q.init();
globalQuantizer = q;
return q;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function checkAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason ?? new DOMException('Aborted', 'AbortError');
}
}
async function loadPixels(
source: ImageSource,
options?: ExtractionOptions,
): Promise<PixelData> {
checkAborted(options?.signal);
const loader = await getLoader(options?.loader);
return loader.load(source, options?.signal);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Get the single dominant color from an image.
*
* ```ts
* const color = await getColor(imgElement);
* console.log(color.hex()); // '#e84393'
* ```
*/
export async function getColor(
source: ImageSource,
options?: ExtractionOptions,
): Promise<Color | null> {
const palette = await getPalette(source, {
colorCount: 5,
...options,
});
return palette ? palette[0] : null;
}
/**
* Get a color palette from an image.
*
* ```ts
* const palette = await getPalette(imgElement, { colorCount: 5 });
* palette.forEach(c => console.log(c.hex()));
* ```
*/
export async function getPalette(
source: ImageSource,
options?: ExtractionOptions,
): Promise<Color[] | null> {
const opts = validateOptions(options ?? {});
checkAborted(options?.signal);
// Worker path (browser only)
if (options?.worker) {
const { isWorkerSupported, extractInWorker } = await import(
'./worker/manager.js'
);
if (isWorkerSupported()) {
const { data, width, height } = await loadPixels(source, options);
const { createPixelArray } = await import('./pipeline.js');
const pixelArray = createPixelArray(data, width * height, opts.quality, {
ignoreWhite: opts.ignoreWhite,
whiteThreshold: opts.whiteThreshold,
alphaThreshold: opts.alphaThreshold,
minSaturation: opts.minSaturation,
});
return extractInWorker(pixelArray, opts.colorCount, options?.signal);
}
// Fall through to main thread if workers not supported
}
const [pixels, quantizer] = await Promise.all([
loadPixels(source, options),
getQuantizer(options?.quantizer),
]);
checkAborted(options?.signal);
return extractPalette(
pixels.data,
pixels.width,
pixels.height,
opts,
quantizer,
);
}
/**
* Get semantic swatches (Vibrant, Muted, etc.) from an image.
*
* ```ts
* const swatches = await getSwatches(imgElement);
* console.log(swatches.Vibrant?.color.hex());
* ```
*/
export async function getSwatches(
source: ImageSource,
options?: ExtractionOptions,
): Promise<SwatchMap> {
const palette = await getPalette(source, {
colorCount: 16,
...options,
});
return classifySwatches(palette ?? []);
}
/**
* Progressively extract a palette with increasing quality.
* Yields intermediate results so the UI can update incrementally.
*
* ```ts
* for await (const { palette, progress, done } of getPaletteProgressive(img)) {
* updateUI(palette, progress);
* }
* ```
*/
export async function* getPaletteProgressive(
source: ImageSource,
options?: ExtractionOptions,
): AsyncGenerator<ProgressiveResult> {
const opts = validateOptions(options ?? {});
const [pixels, quantizer] = await Promise.all([
loadPixels(source, options),
getQuantizer(options?.quantizer),
]);
yield* extractProgressive(
pixels.data,
pixels.width,
pixels.height,
opts,
quantizer,
options?.signal,
);
}
================================================
FILE: src/cli.ts
================================================
import { parseArgs } from 'node:util';
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { getColor, getPalette, getSwatches } from './api.js';
import type { Color, SwatchMap, SwatchRole } from './types.js';
// ---------------------------------------------------------------------------
// Version
// ---------------------------------------------------------------------------
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
// ---------------------------------------------------------------------------
// Help text
// ---------------------------------------------------------------------------
const HELP = `
Usage: colorthief [command] <file...> [options]
Commands:
color Extract dominant color (default)
palette Extract color palette
swatches Extract semantic swatches
Arguments:
<file...> Image file path(s). Use "-" for stdin.
Options:
--json Output as JSON
--css Output as CSS custom properties
--count <n> Number of palette colors (2-20, default 10)
--quality <n> Sampling quality (1=best, default 10)
--color-space <s> Color space: rgb or oklch (default oklch)
-h, --help Show this help
-v, --version Show version
`.trim();
// ---------------------------------------------------------------------------
// Arg parsing
// ---------------------------------------------------------------------------
type Command = 'color' | 'palette' | 'swatches';
interface CliArgs {
command: Command;
files: string[];
json: boolean;
css: boolean;
count: number;
quality: number;
colorSpace: 'rgb' | 'oklch';
}
function parseCliArgs(argv: string[]): CliArgs {
const { values, positionals } = parseArgs({
args: argv.slice(2),
options: {
json: { type: 'boolean', default: false },
css: { type: 'boolean', default: false },
count: { type: 'string', default: '10' },
quality: { type: 'string', default: '10' },
'color-space': { type: 'string', default: 'oklch' },
help: { type: 'boolean', short: 'h', default: false },
version: { type: 'boolean', short: 'v', default: false },
},
allowPositionals: true,
strict: true,
});
if (values.help) {
console.log(HELP);
process.exit(0);
}
if (values.version) {
console.log(version);
process.exit(0);
}
let command: Command = 'color';
const files: string[] = [];
for (const pos of positionals) {
if (pos === 'color' || pos === 'palette' || pos === 'swatches') {
if (files.length === 0) {
command = pos;
continue;
}
}
files.push(pos);
}
if (files.length === 0) {
// Check if stdin is piped
if (!process.stdin.isTTY) {
files.push('-');
} else {
console.error('Error: No input files specified.\n');
console.log(HELP);
process.exit(1);
}
}
const count = parseInt(values.count as string, 10);
if (isNaN(count) || count < 2 || count > 20) {
console.error('Error: --count must be between 2 and 20.');
process.exit(1);
}
const quality = parseInt(values.quality as string, 10);
if (isNaN(quality) || quality < 1) {
console.error('Error: --quality must be a positive integer.');
process.exit(1);
}
const colorSpace = values['color-space'] as string;
if (colorSpace !== 'rgb' && colorSpace !== 'oklch') {
console.error('Error: --color-space must be "rgb" or "oklch".');
process.exit(1);
}
return {
command,
files,
json: values.json as boolean,
css: values.css as boolean,
count,
quality,
colorSpace,
};
}
// ---------------------------------------------------------------------------
// Stdin reader
// ---------------------------------------------------------------------------
async function readStdin(): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks);
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
const supportsColor = !process.env['NO_COLOR'] && process.stdout.isTTY;
function ansiSwatch(hex: string): string {
if (!supportsColor) return hex;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `\x1b[38;2;${r};${g};${b}m\u2587\u2587\x1b[0m ${hex}`;
}
function colorToJson(c: Color): Record<string, unknown> {
return {
hex: c.hex(),
rgb: c.rgb(),
hsl: c.hsl(),
oklch: c.oklch(),
isDark: c.isDark,
population: c.population,
proportion: c.proportion,
};
}
function swatchMapToJson(swatches: SwatchMap): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const role of Object.keys(swatches) as SwatchRole[]) {
const s = swatches[role];
result[role] = s ? colorToJson(s.color) : null;
}
return result;
}
// ---------------------------------------------------------------------------
// CSS output
// ---------------------------------------------------------------------------
function colorCss(c: Color): string {
return `:root {\n --color-dominant: ${c.hex()};\n}`;
}
function paletteCss(colors: Color[]): string {
const props = colors.map((c, i) => ` --color-${i + 1}: ${c.hex()};`).join('\n');
return `:root {\n${props}\n}`;
}
function swatchesCss(swatches: SwatchMap): string {
const props: string[] = [];
for (const role of Object.keys(swatches) as SwatchRole[]) {
const s = swatches[role];
const kebab = role.replace(/([A-Z])/g, '-$1').toLowerCase();
props.push(` --swatch${kebab}: ${s ? s.color.hex() : 'none'};`);
}
return `:root {\n${props.join('\n')}\n}`;
}
// ---------------------------------------------------------------------------
// ANSI output
// ---------------------------------------------------------------------------
function colorAnsi(c: Color): string {
return ansiSwatch(c.hex());
}
function paletteAnsi(colors: Color[]): string {
return colors.map(c => ansiSwatch(c.hex())).join('\n');
}
function swatchesAnsi(swatches: SwatchMap): string {
const lines: string[] = [];
for (const role of Object.keys(swatches) as SwatchRole[]) {
const s = swatches[role];
const label = role.padEnd(14);
lines.push(s ? `${label} ${ansiSwatch(s.color.hex())}` : `${label} —`);
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function processFile(
source: string | Buffer,
args: CliArgs,
): Promise<{ colorResult?: Color | null; paletteResult?: Color[] | null; swatchResult?: SwatchMap }> {
const opts = {
colorCount: args.count,
quality: args.quality,
colorSpace: args.colorSpace as 'rgb' | 'oklch',
};
switch (args.command) {
case 'color': {
const color = await getColor(source, opts);
return { colorResult: color };
}
case 'palette': {
const palette = await getPalette(source, opts);
return { paletteResult: palette };
}
case 'swatches': {
const swatches = await getSwatches(source, opts);
return { swatchResult: swatches };
}
}
}
function formatResult(
result: Awaited<ReturnType<typeof processFile>>,
args: CliArgs,
): string {
if (args.json) {
if (result.colorResult !== undefined) {
return JSON.stringify(result.colorResult ? colorToJson(result.colorResult) : null, null, 2);
}
if (result.paletteResult !== undefined) {
return JSON.stringify(result.paletteResult ? result.paletteResult.map(colorToJson) : null, null, 2);
}
if (result.swatchResult !== undefined) {
return JSON.stringify(swatchMapToJson(result.swatchResult), null, 2);
}
}
if (args.css) {
if (result.colorResult !== undefined && result.colorResult) {
return colorCss(result.colorResult);
}
if (result.paletteResult !== undefined && result.paletteResult) {
return paletteCss(result.paletteResult);
}
if (result.swatchResult !== undefined) {
return swatchesCss(result.swatchResult!);
}
}
// Default ANSI
if (result.colorResult !== undefined) {
return result.colorResult ? colorAnsi(result.colorResult) : '(no color found)';
}
if (result.paletteResult !== undefined) {
return result.paletteResult ? paletteAnsi(result.paletteResult) : '(no palette found)';
}
if (result.swatchResult !== undefined) {
return swatchesAnsi(result.swatchResult);
}
return '';
}
async function main(): Promise<void> {
const args = parseCliArgs(process.argv);
const multiFile = args.files.length > 1;
if (args.json && multiFile) {
const combined: Record<string, unknown> = {};
for (const file of args.files) {
const source = file === '-' ? await readStdin() : file;
const label = file === '-' ? 'stdin' : file;
const result = await processFile(source, args);
if (result.colorResult !== undefined) {
combined[label] = result.colorResult ? colorToJson(result.colorResult) : null;
} else if (result.paletteResult !== undefined) {
combined[label] = result.paletteResult ? result.paletteResult.map(colorToJson) : null;
} else if (result.swatchResult !== undefined) {
combined[label] = swatchMapToJson(result.swatchResult);
}
}
console.log(JSON.stringify(combined, null, 2));
return;
}
for (const file of args.files) {
const source = file === '-' ? await readStdin() : file;
const result = await processFile(source, args);
const output = formatResult(result, args);
if (multiFile) {
const label = file === '-' ? 'stdin' : file;
console.log(`${label}:`);
}
console.log(output);
}
}
main().catch((err) => {
if (
err?.code === 'ERR_MODULE_NOT_FOUND' ||
err?.code === 'MODULE_NOT_FOUND' ||
(typeof err?.message === 'string' && err.message.includes('Cannot find module') && err.message.includes('sharp'))
) {
console.error(
'Error: sharp is required for image decoding but was not found.\n\n' +
'Install it alongside colorthief:\n\n' +
' npm install -g colorthief sharp\n'
);
process.exit(1);
}
console.error(err.message || err);
process.exit(1);
});
================================================
FILE: src/color-space.ts
================================================
import type { OKLCH } from './types.js';
// ---------------------------------------------------------------------------
// sRGB ↔ Linear
// ---------------------------------------------------------------------------
/** Convert a single sRGB channel (0–255) to linear (0–1). */
export function srgbToLinear(c: number): number {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
/** Convert a linear channel (0–1) back to sRGB (0–255). */
export function linearToSrgb(c: number): number {
const s = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
return Math.round(Math.max(0, Math.min(255, s * 255)));
}
// ---------------------------------------------------------------------------
// RGB → OKLab → OKLCH
// ---------------------------------------------------------------------------
/** Convert sRGB (0–255 each) to OKLCH. */
export function rgbToOklch(r: number, g: number, b: number): OKLCH {
// sRGB → linear
const lr = srgbToLinear(r);
const lg = srgbToLinear(g);
const lb = srgbToLinear(b);
// Linear sRGB → LMS (using Oklab M1 matrix)
const l_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
const m_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
const s_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
// Cube root (LMS → Lab cone response)
const l3 = Math.cbrt(l_);
const m3 = Math.cbrt(m_);
const s3 = Math.cbrt(s_);
// LMS cone response → OKLab
const L = 0.2104542553 * l3 + 0.7936177850 * m3 - 0.0040720468 * s3;
const a = 1.9779984951 * l3 - 2.4285922050 * m3 + 0.4505937099 * s3;
const bLab = 0.0259040371 * l3 + 0.7827717662 * m3 - 0.8086757660 * s3;
// OKLab → OKLCH
const C = Math.sqrt(a * a + bLab * bLab);
let H = Math.atan2(bLab, a) * (180 / Math.PI);
if (H < 0) H += 360;
return { l: L, c: C, h: H };
}
// ---------------------------------------------------------------------------
// OKLCH → OKLab → RGB
// ---------------------------------------------------------------------------
/** Convert OKLCH back to sRGB (0–255 each). Clamps out-of-gamut values. */
export function oklchToRgb(l: number, c: number, h: number): [number, number, number] {
// OKLCH → OKLab
const hRad = h * (Math.PI / 180);
const a = c * Math.cos(hRad);
const bLab = c * Math.sin(hRad);
// OKLab → LMS cone response
const l3 = l + 0.3963377774 * a + 0.2158037573 * bLab;
const m3 = l - 0.1055613458 * a - 0.0638541728 * bLab;
const s3 = l - 0.0894841775 * a - 1.2914855480 * bLab;
// Cube (cone response → LMS)
const l_ = l3 * l3 * l3;
const m_ = m3 * m3 * m3;
const s_ = s3 * s3 * s3;
// LMS → linear sRGB (inverse of M1)
const lr = +4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_;
const lg = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_;
const lb = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_;
return [linearToSrgb(lr), linearToSrgb(lg), linearToSrgb(lb)];
}
// ---------------------------------------------------------------------------
// Batch conversion helpers for OKLCH quantization pipeline
// ---------------------------------------------------------------------------
/**
* Convert an array of RGB pixel triplets to OKLCH, scaled to 0–255 for
* compatibility with the MMCQ quantizer (which expects integer ranges).
*
* Scaling: L (0–1) → 0–255, C (0–0.4) → 0–255, H (0–360) → 0–255
*/
export function pixelsRgbToOklchScaled(
pixels: Array<[number, number, number]>,
): Array<[number, number, number]> {
const out: Array<[number, number, number]> = new Array(pixels.length);
for (let i = 0; i < pixels.length; i++) {
const [r, g, b] = pixels[i];
const { l, c, h } = rgbToOklch(r, g, b);
out[i] = [
Math.round(l * 255),
Math.round((c / 0.4) * 255),
Math.round((h / 360) * 255),
];
}
return out;
}
/**
* Convert scaled OKLCH palette entries back to RGB.
*/
export function paletteOklchScaledToRgb(
colors: Array<{ color: [number, number, number]; population: number }>,
): Array<{ color: [number, number, number]; population: number }> {
return colors.map(({ color: [ls, cs, hs], population }) => {
const l = ls / 255;
const c = (cs / 255) * 0.4;
const h = (hs / 255) * 360;
return { color: oklchToRgb(l, c, h), population };
});
}
================================================
FILE: src/color.ts
================================================
import type { RGB, HSL, OKLCH, Color, ContrastInfo, CssColorFormat } from './types.js';
import { rgbToOklch } from './color-space.js';
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function rgbToHsl(r: number, g: number, b: number): HSL {
const r1 = r / 255;
const g1 = g / 255;
const b1 = b / 255;
const max = Math.max(r1, g1, b1);
const min = Math.min(r1, g1, b1);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r1) {
h = ((g1 - b1) / d + (g1 < b1 ? 6 : 0)) / 6;
} else if (max === g1) {
h = ((b1 - r1) / d + 2) / 6;
} else {
h = ((r1 - g1) / d + 4) / 6;
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
/** WCAG relative luminance from sRGB 0–255. */
function relativeLuminance(r: number, g: number, b: number): number {
const toLinear = (c: number) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
/** WCAG contrast ratio between two luminances (always ≥ 1). */
function contrastRatio(l1: number, l2: number): number {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// ---------------------------------------------------------------------------
// ColorImpl
// ---------------------------------------------------------------------------
class ColorImpl implements Color {
private readonly _r: number;
private readonly _g: number;
private readonly _b: number;
readonly population: number;
readonly proportion: number;
private _hsl: HSL | undefined;
private _oklch: OKLCH | undefined;
private _luminance: number | undefined;
private _contrast: ContrastInfo | undefined;
constructor(r: number, g: number, b: number, population: number, proportion: number) {
this._r = r;
this._g = g;
this._b = b;
this.population = population;
this.proportion = proportion;
}
rgb(): RGB {
return { r: this._r, g: this._g, b: this._b };
}
hex(): string {
const toHex = (n: number) => n.toString(16).padStart(2, '0');
return `#${toHex(this._r)}${toHex(this._g)}${toHex(this._b)}`;
}
hsl(): HSL {
if (!this._hsl) {
this._hsl = rgbToHsl(this._r, this._g, this._b);
}
return this._hsl;
}
oklch(): OKLCH {
if (!this._oklch) {
this._oklch = rgbToOklch(this._r, this._g, this._b);
}
return this._oklch;
}
css(format: CssColorFormat = 'rgb'): string {
switch (format) {
case 'hsl': {
const { h, s, l } = this.hsl();
return `hsl(${h}, ${s}%, ${l}%)`;
}
case 'oklch': {
const { l, c, h } = this.oklch();
return `oklch(${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)})`;
}
case 'rgb':
default:
return `rgb(${this._r}, ${this._g}, ${this._b})`;
}
}
array(): [number, number, number] {
return [this._r, this._g, this._b];
}
toString(): string {
return this.hex();
}
get textColor(): string {
return this.isDark ? '#ffffff' : '#000000';
}
private get luminance(): number {
if (this._luminance === undefined) {
this._luminance = relativeLuminance(this._r, this._g, this._b);
}
return this._luminance;
}
get isDark(): boolean {
return this.luminance <= 0.179;
}
get isLight(): boolean {
return !this.isDark;
}
get contrast(): ContrastInfo {
if (!this._contrast) {
const lum = this.luminance;
const white = contrastRatio(lum, 1); // white luminance = 1
const black = contrastRatio(lum, 0); // black luminance = 0
const foreground = this.isDark
? createColor(255, 255, 255, 0, 0)
: createColor(0, 0, 0, 0, 0);
this._contrast = {
white: Math.round(white * 100) / 100,
black: Math.round(black * 100) / 100,
foreground,
};
}
return this._contrast;
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/** Create a Color object from RGB components, population count, and proportion. */
export function createColor(
r: number,
g: number,
b: number,
population: number,
proportion: number = 0,
): Color {
return new ColorImpl(r, g, b, population, proportion);
}
================================================
FILE: src/index.ts
================================================
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export {
getColor,
getPalette,
getSwatches,
getPaletteProgressive,
configure,
} from './api.js';
// ---------------------------------------------------------------------------
// Sync browser API
// ---------------------------------------------------------------------------
export {
getColorSync,
getPaletteSync,
getSwatchesSync,
} from './sync.js';
// ---------------------------------------------------------------------------
// Live extraction (browser only)
// ---------------------------------------------------------------------------
export { observe } from './observe.js';
export type { ObservableSource, ObserveOptions, ObserveController } from './observe.js';
// ---------------------------------------------------------------------------
// Color factory
// ---------------------------------------------------------------------------
export { createColor } from './color.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type {
RGB,
HSL,
OKLCH,
FilterOptions,
ColorSpace,
ExtractionOptions,
ContrastInfo,
Color,
CssColorFormat,
SwatchRole,
Swatch,
SwatchMap,
BrowserSource,
NodeSource,
ImageSource,
ProgressiveResult,
} from './types.js';
export type { SyncExtractionOptions } from './sync.js';
================================================
FILE: src/internals.browser.ts
================================================
// ---------------------------------------------------------------------------
// colorthief/internals (browser build)
//
// Same as internals.ts but without Node-specific exports (NodePixelLoader,
// createNodeLoader, NodeImageDecoder) so browser bundlers never see sharp.
// ---------------------------------------------------------------------------
// Quantizers
export { MmcqQuantizer } from './quantizers/mmcq.js';
export { WasmQuantizer } from './quantizers/wasm.js';
// Loaders
export { BrowserPixelLoader } from './loaders/browser.js';
// Swatches (standalone classifier)
export { classifySwatches } from './swatches.js';
// Color space conversions
export {
rgbToOklch,
oklchToRgb,
srgbToLinear,
linearToSrgb,
pixelsRgbToOklchScaled,
paletteOklchScaledToRgb,
} from './color-space.js';
// Worker manager
export {
isWorkerSupported,
extractInWorker,
terminateWorker,
} from './worker/manager.js';
// Low-level pipeline
export {
validateOptions,
createPixelArray,
computeFallbackColor,
extractPalette,
} from './pipeline.js';
// Types not needed by most consumers
export type {
PixelBuffer,
PixelData,
PixelLoader,
Quantizer,
} from './types.js';
================================================
FILE: src/internals.ts
================================================
// ---------------------------------------------------------------------------
// colorthief/internals
//
// Power-user exports: loaders, quantizers, color-space math, worker manager.
// Most consumers should use the main 'colorthief' entry point instead.
// ---------------------------------------------------------------------------
// Quantizers
export { MmcqQuantizer } from './quantizers/mmcq.js';
export { WasmQuantizer } from './quantizers/wasm.js';
// Loaders
export { BrowserPixelLoader } from './loaders/browser.js';
export { NodePixelLoader, createNodeLoader } from './loaders/node.js';
export type { NodeImageDecoder } from './loaders/node.js';
// Swatches (standalone classifier)
export { classifySwatches } from './swatches.js';
// Color space conversions
export {
rgbToOklch,
oklchToRgb,
srgbToLinear,
linearToSrgb,
pixelsRgbToOklchScaled,
paletteOklchScaledToRgb,
} from './color-space.js';
// Worker manager
export {
isWorkerSupported,
extractInWorker,
terminateWorker,
} from './worker/manager.js';
// Low-level pipeline
export {
validateOptions,
createPixelArray,
computeFallbackColor,
extractPalette,
} from './pipeline.js';
// Types not needed by most consumers
export type {
PixelBuffer,
PixelData,
PixelLoader,
Quantizer,
} from './types.js';
================================================
FILE: src/loaders/browser.ts
================================================
import type { BrowserSource, PixelData, PixelLoader } from '../types.js';
/**
* Browser pixel loader. Extracts RGBA pixel data from DOM image sources
* using an off-screen canvas.
*/
export class BrowserPixelLoader implements PixelLoader<BrowserSource> {
async load(source: BrowserSource): Promise<PixelData> {
if (typeof HTMLImageElement !== 'undefined' && source instanceof HTMLImageElement) {
return this.loadFromImage(source);
}
if (typeof HTMLCanvasElement !== 'undefined' && source instanceof HTMLCanvasElement) {
return this.loadFromCanvas(source);
}
if (typeof ImageData !== 'undefined' && source instanceof ImageData) {
return {
data: source.data,
width: source.width,
height: source.height,
};
}
if (typeof HTMLVideoElement !== 'undefined' && source instanceof HTMLVideoElement) {
return this.loadFromVideo(source);
}
if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) {
return this.loadFromImageBitmap(source);
}
if (typeof OffscreenCanvas !== 'undefined' && source instanceof OffscreenCanvas) {
return this.loadFromOffscreenCanvas(source);
}
throw new Error(
'Unsupported source type. Expected HTMLImageElement, HTMLCanvasElement, HTMLVideoElement, ImageData, ImageBitmap, or OffscreenCanvas.',
);
}
private loadFromImage(img: HTMLImageElement): PixelData {
if (!img.complete) {
throw new Error(
'Image has not finished loading. Wait for the "load" event before calling getColor/getPalette.',
);
}
if (!img.naturalWidth) {
throw new Error(
'Image has no dimensions. It may not have loaded successfully.',
);
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const width = (canvas.width = img.naturalWidth);
const height = (canvas.height = img.naturalHeight);
ctx.drawImage(img, 0, 0, width, height);
try {
const imageData = ctx.getImageData(0, 0, width, height);
return { data: imageData.data, width, height };
} catch (e: unknown) {
if (e instanceof DOMException && e.name === 'SecurityError') {
const err = new Error(
'Image is tainted by cross-origin data. Add crossorigin="anonymous" to the <img> tag and ensure the server sends appropriate CORS headers.',
);
err.cause = e;
throw err;
}
throw e;
}
}
private loadFromCanvas(canvas: HTMLCanvasElement): PixelData {
const ctx = canvas.getContext('2d')!;
const { width, height } = canvas;
const imageData = ctx.getImageData(0, 0, width, height);
return { data: imageData.data, width, height };
}
private loadFromVideo(video: HTMLVideoElement): PixelData {
if (video.readyState < 2) {
throw new Error(
'Video is not ready. Wait for the "loadeddata" or "canplay" event before calling getColor/getPalette.',
);
}
const width = video.videoWidth;
const height = video.videoHeight;
if (!width || !height) {
throw new Error(
'Video has no dimensions. It may not have loaded successfully.',
);
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = width;
canvas.height = height;
ctx.drawImage(video, 0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height);
return { data: imageData.data, width, height };
}
private loadFromOffscreenCanvas(canvas: OffscreenCanvas): PixelData {
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D;
if (!ctx) {
throw new Error(
'Could not get 2D context from OffscreenCanvas.',
);
}
const { width, height } = canvas;
const imageData = ctx.getImageData(0, 0, width, height);
return { data: imageData.data, width, height };
}
private loadFromImageBitmap(bitmap: ImageBitmap): PixelData {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = bitmap.width;
canvas.height = bitmap.height;
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
return { data: imageData.data, width: bitmap.width, height: bitmap.height };
}
}
================================================
FILE: src/loaders/node.ts
================================================
import type { NodeSource, PixelData, PixelLoader } from '../types.js';
/** Custom decoder signature for pluggable Node decoders. */
export type NodeImageDecoder = (
input: string | Buffer,
) => Promise<{ data: Uint8Array; width: number; height: number }>;
interface NodeLoaderOptions {
/** Override the default sharp-based decoder. */
decoder?: NodeImageDecoder;
}
/**
* Node.js pixel loader. Uses `sharp` (dynamically imported) to decode images
* into raw RGBA pixel buffers. Accepts file paths or Buffers.
*
* The sharp dependency is optional — use `createNodeLoader({ decoder })`
* to supply a custom decoder if sharp is not available.
*/
export class NodePixelLoader implements PixelLoader<NodeSource> {
private readonly decoder: NodeImageDecoder;
constructor(options?: NodeLoaderOptions) {
this.decoder = options?.decoder ?? defaultSharpDecoder;
}
async load(source: NodeSource): Promise<PixelData> {
const result = await this.decoder(source);
return {
data: result.data,
width: result.width,
height: result.height,
};
}
}
/** Default decoder using sharp. Dynamically imports sharp so it stays optional. */
async function defaultSharpDecoder(
input: string | Buffer,
): Promise<{ data: Uint8Array; width: number; height: number }> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let sharpFn: any;
try {
const mod = await import('sharp');
sharpFn = mod.default ?? mod;
} catch {
throw new Error(
'sharp is required for Node.js image loading. Install it with: npm install sharp',
);
}
const image = sharpFn(input).ensureAlpha();
const { width, height } = await image.metadata();
if (!width || !height) {
throw new Error('Could not determine image dimensions.');
}
const { data } = await image.raw().toBuffer({ resolveWithObject: true });
return { data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), width, height };
}
/** Factory to create a NodePixelLoader with optional custom decoder. */
export function createNodeLoader(options?: NodeLoaderOptions): NodePixelLoader {
return new NodePixelLoader(options);
}
================================================
FILE: src/observe.ts
================================================
/**
* Live extraction mode — observe() with reactive updates.
*
* Watches a source element (video, canvas, or img) and emits palette
* updates via an onChange callback. Uses requestAnimationFrame with
* throttle for video/canvas, and MutationObserver for img src changes.
*
* Browser-only — relies on DOM APIs.
*/
import type { Color, ColorSpace, FilterOptions } from './types.js';
import { getPaletteSync } from './sync.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Element types that can be observed for live palette updates. */
export type ObservableSource =
| HTMLVideoElement
| HTMLCanvasElement
| HTMLImageElement;
/** Options for observe(). */
export interface ObserveOptions extends FilterOptions {
/** Minimum milliseconds between palette updates. @default 200 */
throttle?: number;
/** Number of colors in the palette (2–20). @default 5 */
colorCount?: number;
/** Sampling quality (1 = highest). @default 10 */
quality?: number;
/** Color space for quantization. @default 'oklch' */
colorSpace?: ColorSpace;
/** Called whenever a new palette is extracted. */
onChange: (palette: Color[]) => void;
}
/** Controller returned by observe() to stop watching. */
export interface ObserveController {
/** Stop observing and clean up all listeners/timers. */
stop(): void;
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
/**
* Watch a source element and reactively extract palettes as it changes.
*
* - **HTMLVideoElement** — extracts from the current frame on each animation
* frame (throttled). Only runs while the video is playing.
* - **HTMLCanvasElement** — polls on each animation frame (throttled).
* - **HTMLImageElement** — extracts immediately, then watches for `src`/`srcset`
* attribute changes via MutationObserver.
*
* ```ts
* const controller = observe(videoElement, {
* throttle: 200,
* colorCount: 5,
* onChange(palette) {
* updateAmbientBackground(palette);
* },
* });
*
* // Later
* controller.stop();
* ```
*/
export function observe(
source: ObservableSource,
options: ObserveOptions,
): ObserveController {
const {
throttle = 200,
onChange,
...extractionOptions
} = options;
let stopped = false;
let rafId: number | null = null;
let mutationObserver: MutationObserver | null = null;
let lastExtractTime = 0;
// Cleanup handles for event listeners
const cleanups: Array<() => void> = [];
function extract(): void {
try {
const palette = getPaletteSync(source, extractionOptions);
if (palette && palette.length > 0) {
onChange(palette);
}
} catch {
// Skip this frame on error (CORS, not loaded, etc.)
}
}
function tick(): void {
if (stopped) return;
const now = performance.now();
if (now - lastExtractTime >= throttle) {
if (source instanceof HTMLVideoElement) {
// Only extract when the video has data and is playing
if (source.readyState >= 2 && !source.paused && !source.ended) {
extract();
lastExtractTime = now;
}
} else {
// Canvas — always extract
extract();
lastExtractTime = now;
}
}
rafId = requestAnimationFrame(tick);
}
// ----- HTMLImageElement: MutationObserver-based -----
if (source instanceof HTMLImageElement) {
// Extract immediately if already loaded
if (source.complete && source.naturalWidth) {
extract();
} else {
const onLoad = (): void => {
extract();
source.removeEventListener('load', onLoad);
};
source.addEventListener('load', onLoad);
cleanups.push(() => source.removeEventListener('load', onLoad));
}
// Watch for src / srcset attribute changes
mutationObserver = new MutationObserver(() => {
if (source.complete && source.naturalWidth) {
extract();
} else {
const onLoad = (): void => {
extract();
source.removeEventListener('load', onLoad);
};
source.addEventListener('load', onLoad);
}
});
mutationObserver.observe(source, {
attributes: true,
attributeFilter: ['src', 'srcset'],
});
// ----- HTMLVideoElement: rAF + play/pause awareness -----
} else if (source instanceof HTMLVideoElement) {
// Start the rAF loop — it checks readyState/paused internally
rafId = requestAnimationFrame(tick);
// Also extract on seeked so scrubbing works
const onSeeked = (): void => {
if (!stopped) extract();
};
source.addEventListener('seeked', onSeeked);
cleanups.push(() => source.removeEventListener('seeked', onSeeked));
// ----- HTMLCanvasElement: rAF polling -----
} else {
rafId = requestAnimationFrame(tick);
}
return {
stop(): void {
stopped = true;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (mutationObserver) {
mutationObserver.disconnect();
mutationObserver = null;
}
for (const fn of cleanups) fn();
cleanups.length = 0;
},
};
}
================================================
FILE: src/pipeline.ts
================================================
import type {
Color,
ExtractionOptions,
FilterOptions,
PixelBuffer,
Quantizer,
} from './types.js';
import { createColor } from './color.js';
import {
pixelsRgbToOklchScaled,
paletteOklchScaledToRgb,
} from './color-space.js';
// ---------------------------------------------------------------------------
// Validate & normalize options
// ---------------------------------------------------------------------------
export interface ValidatedOptions {
colorCount: number;
quality: number;
ignoreWhite: boolean;
whiteThreshold: number;
alphaThreshold: number;
minSaturation: number;
colorSpace: 'rgb' | 'oklch';
}
export function validateOptions(options: ExtractionOptions): ValidatedOptions {
let { colorCount, quality } = options;
if (typeof colorCount === 'undefined' || !Number.isInteger(colorCount)) {
colorCount = 10;
} else if (colorCount === 1) {
throw new Error(
'colorCount should be between 2 and 20. To get one color, call getColor() instead of getPalette()',
);
} else {
colorCount = Math.max(colorCount, 2);
colorCount = Math.min(colorCount, 20);
}
if (
typeof quality === 'undefined' ||
!Number.isInteger(quality) ||
quality < 1
) {
quality = 10;
}
const ignoreWhite =
options.ignoreWhite !== undefined ? !!options.ignoreWhite : true;
const whiteThreshold =
typeof options.whiteThreshold === 'number' ? options.whiteThreshold : 250;
const alphaThreshold =
typeof options.alphaThreshold === 'number' ? options.alphaThreshold : 125;
const minSaturation =
typeof options.minSaturation === 'number'
? Math.max(0, Math.min(1, options.minSaturation))
: 0;
const colorSpace = options.colorSpace ?? 'oklch';
return {
colorCount,
quality,
ignoreWhite,
whiteThreshold,
alphaThreshold,
minSaturation,
colorSpace,
};
}
// ---------------------------------------------------------------------------
// Pixel sampling
// ---------------------------------------------------------------------------
export function createPixelArray(
data: PixelBuffer,
pixelCount: number,
quality: number,
filterOptions: FilterOptions,
): Array<[number, number, number]> {
const {
ignoreWhite = true,
whiteThreshold = 250,
alphaThreshold = 125,
minSaturation = 0,
} = filterOptions;
const pixelArray: Array<[number, number, number]> = [];
for (let i = 0; i < pixelCount; i += quality) {
const offset = i * 4;
const r = data[offset];
const g = data[offset + 1];
const b = data[offset + 2];
const a = data[offset + 3];
// Skip transparent pixels
if (a !== undefined && a < alphaThreshold) continue;
// Skip white pixels
if (
ignoreWhite &&
r > whiteThreshold &&
g > whiteThreshold &&
b > whiteThreshold
)
continue;
// Skip low-saturation pixels
if (minSaturation > 0) {
const max = Math.max(r, g, b);
if (max === 0 || (max - Math.min(r, g, b)) / max < minSaturation)
continue;
}
pixelArray.push([r, g, b]);
}
return pixelArray;
}
// ---------------------------------------------------------------------------
// Fallback color (average)
// ---------------------------------------------------------------------------
export function computeFallbackColor(
data: PixelBuffer,
pixelCount: number,
quality: number,
): [number, number, number] | null {
let rTotal = 0;
let gTotal = 0;
let bTotal = 0;
let count = 0;
for (let i = 0; i < pixelCount; i += quality) {
const offset = i * 4;
rTotal += data[offset];
gTotal += data[offset + 1];
bTotal += data[offset + 2];
count++;
}
if (count === 0) return null;
return [
Math.round(rTotal / count),
Math.round(gTotal / count),
Math.round(bTotal / count),
];
}
// ---------------------------------------------------------------------------
// Main extraction pipeline
// ---------------------------------------------------------------------------
export function extractPalette(
data: PixelBuffer,
width: number,
height: number,
opts: ValidatedOptions,
quantizer: Quantizer,
): Color[] | null {
const pixelCount = width * height;
const filterOptions: FilterOptions = {
ignoreWhite: opts.ignoreWhite,
whiteThreshold: opts.whiteThreshold,
alphaThreshold: opts.alphaThreshold,
minSaturation: opts.minSaturation,
};
let pixelArray = createPixelArray(data, pixelCount, opts.quality, filterOptions);
// Progressively relax filters if all pixels were excluded
if (pixelArray.length === 0) {
pixelArray = createPixelArray(data, pixelCount, opts.quality, {
...filterOptions,
ignoreWhite: false,
});
}
if (pixelArray.length === 0) {
pixelArray = createPixelArray(data, pixelCount, opts.quality, {
...filterOptions,
ignoreWhite: false,
alphaThreshold: 0,
});
}
// OKLCH quantization path
let quantized: Array<{ color: [number, number, number]; population: number }>;
if (opts.colorSpace === 'oklch') {
const scaled = pixelsRgbToOklchScaled(pixelArray);
quantized = paletteOklchScaledToRgb(
quantizer.quantize(scaled, opts.colorCount),
);
} else {
quantized = quantizer.quantize(pixelArray, opts.colorCount);
}
if (quantized.length > 0) {
const totalPopulation = quantized.reduce((sum, q) => sum + q.population, 0);
return quantized.map(({ color: [r, g, b], population }) =>
createColor(r, g, b, population, totalPopulation > 0 ? population / totalPopulation : 0),
);
}
// Fallback: average all pixels
const fallback = computeFallbackColor(data, pixelCount, opts.quality);
return fallback ? [createColor(fallback[0], fallback[1], fallback[2], 1, 1)] : null;
}
================================================
FILE: src/progressive.ts
================================================
import type {
Color,
PixelBuffer,
ProgressiveResult,
Quantizer,
} from './types.js';
import { extractPalette, type ValidatedOptions } from './pipeline.js';
/** Quality divisors for the 3 progressive passes. */
const PASSES = [
{ divisor: 16, progress: 0.06 },
{ divisor: 4, progress: 0.25 },
{ divisor: 1, progress: 1.0 },
];
/** Yield between passes so the UI can repaint. */
function yieldToMain(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
/**
* Progressive palette extraction. Runs 3 passes with increasing quality
* (16x skip → 4x skip → full quality), yielding intermediate results.
*/
export async function* extractProgressive(
data: PixelBuffer,
width: number,
height: number,
opts: ValidatedOptions,
quantizer: Quantizer,
signal?: AbortSignal,
): AsyncGenerator<ProgressiveResult> {
for (let i = 0; i < PASSES.length; i++) {
if (signal?.aborted) {
throw signal.reason ?? new DOMException('Aborted', 'AbortError');
}
const pass = PASSES[i];
const passOpts: ValidatedOptions = {
...opts,
quality: opts.quality * pass.divisor,
};
const palette = extractPalette(data, width, height, passOpts, quantizer);
const done = i === PASSES.length - 1;
yield {
palette: palette ?? [],
progress: pass.progress,
done,
};
if (!done) {
await yieldToMain();
}
}
}
================================================
FILE: src/quantizers/mmcq.ts
================================================
import type { Quantizer } from '../types.js';
// ---------------------------------------------------------------------------
// Constants (match original quantize library)
// ---------------------------------------------------------------------------
const SIGBITS = 5;
const RSHIFT = 8 - SIGBITS;
const MAX_ITERATIONS = 1000;
const FRACT_BY_POPULATIONS = 0.75;
const HISTO_SIZE = 1 << (3 * SIGBITS);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getColorIndex(r: number, g: number, b: number): number {
return (r << (2 * SIGBITS)) + (g << SIGBITS) + b;
}
// ---------------------------------------------------------------------------
// VBox — a 3-D box in reduced (5-bit) RGB color space
// ---------------------------------------------------------------------------
class VBox {
r1: number;
r2: number;
g1: number;
g2: number;
b1: number;
b2: number;
private readonly histo: Uint32Array;
private _volume: number | undefined;
private _count: number | undefined;
private _avg: [number, number, number] | undefined;
constructor(
r1: number,
r2: number,
g1: number,
g2: number,
b1: number,
b2: number,
histo: Uint32Array,
) {
this.r1 = r1;
this.r2 = r2;
this.g1 = g1;
this.g2 = g2;
this.b1 = b1;
this.b2 = b2;
this.histo = histo;
}
volume(force = false): number {
if (this._volume === undefined || force) {
this._volume =
(this.r2 - this.r1 + 1) *
(this.g2 - this.g1 + 1) *
(this.b2 - this.b1 + 1);
}
return this._volume;
}
count(force = false): number {
if (this._count === undefined || force) {
let npix = 0;
for (let i = this.r1; i <= this.r2; i++) {
for (let j = this.g1; j <= this.g2; j++) {
for (let k = this.b1; k <= this.b2; k++) {
npix += this.histo[getColorIndex(i, j, k)] || 0;
}
}
}
this._count = npix;
}
return this._count;
}
copy(): VBox {
return new VBox(this.r1, this.r2, this.g1, this.g2, this.b1, this.b2, this.histo);
}
avg(force = false): [number, number, number] {
if (this._avg === undefined || force) {
const mult = 1 << RSHIFT;
// Single-color box: return exact color
if (this.r1 === this.r2 && this.g1 === this.g2 && this.b1 === this.b2) {
this._avg = [
this.r1 << RSHIFT,
this.g1 << RSHIFT,
this.b1 << RSHIFT,
];
} else {
let ntot = 0;
let rsum = 0;
let gsum = 0;
let bsum = 0;
for (let i = this.r1; i <= this.r2; i++) {
for (let j = this.g1; j <= this.g2; j++) {
for (let k = this.b1; k <= this.b2; k++) {
const hval = this.histo[getColorIndex(i, j, k)] || 0;
ntot += hval;
rsum += hval * (i + 0.5) * mult;
gsum += hval * (j + 0.5) * mult;
bsum += hval * (k + 0.5) * mult;
}
}
}
if (ntot) {
this._avg = [
~~(rsum / ntot),
~~(gsum / ntot),
~~(bsum / ntot),
];
} else {
this._avg = [
~~((mult * (this.r1 + this.r2 + 1)) / 2),
~~((mult * (this.g1 + this.g2 + 1)) / 2),
~~((mult * (this.b1 + this.b2 + 1)) / 2),
];
}
}
}
return this._avg;
}
}
// ---------------------------------------------------------------------------
// PQueue — lazy-sorted priority queue
// ---------------------------------------------------------------------------
class PQueue<T> {
private contents: T[] = [];
private sorted = false;
constructor(private comparator: (a: T, b: T) => number) {}
private sort(): void {
this.contents.sort(this.comparator);
this.sorted = true;
}
push(item: T): void {
this.contents.push(item);
this.sorted = false;
}
peek(index?: number): T {
if (!this.sorted) this.sort();
return this.contents[index ?? this.contents.length - 1];
}
pop(): T {
if (!this.sorted) this.sort();
return this.contents.pop()!;
}
size(): number {
return this.contents.length;
}
map<U>(fn: (item: T) => U): U[] {
return this.contents.map(fn);
}
}
// ---------------------------------------------------------------------------
// Histogram & initial VBox
// ---------------------------------------------------------------------------
function getHisto(pixels: Array<[number, number, number]>): Uint32Array {
const histo = new Uint32Array(HISTO_SIZE);
for (const pixel of pixels) {
const rval = pixel[0] >> RSHIFT;
const gval = pixel[1] >> RSHIFT;
const bval = pixel[2] >> RSHIFT;
histo[getColorIndex(rval, gval, bval)]++;
}
return histo;
}
function vboxFromPixels(
pixels: Array<[number, number, number]>,
histo: Uint32Array,
): VBox {
let rmin = 1000000;
let rmax = 0;
let gmin = 1000000;
let gmax = 0;
let bmin = 1000000;
let bmax = 0;
for (const pixel of pixels) {
const rval = pixel[0] >> RSHIFT;
const gval = pixel[1] >> RSHIFT;
const bval = pixel[2] >> RSHIFT;
if (rval < rmin) rmin = rval;
else if (rval > rmax) rmax = rval;
if (gval < gmin) gmin = gval;
else if (gval > gmax) gmax = gval;
if (bval < bmin) bmin = bval;
else if (bval > bmax) bmax = bval;
}
return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo);
}
// ---------------------------------------------------------------------------
// Median-cut split
// ---------------------------------------------------------------------------
function medianCutApply(histo: Uint32Array, vbox: VBox): [VBox, VBox | null] | undefined {
if (!vbox.count()) return undefined;
// Only one pixel — no split possible
if (vbox.count() === 1) return [vbox.copy(), null];
const rw = vbox.r2 - vbox.r1 + 1;
const gw = vbox.g2 - vbox.g1 + 1;
const bw = vbox.b2 - vbox.b1 + 1;
const maxw = Math.max(rw, gw, bw);
let total = 0;
const partialsum: number[] = [];
const lookaheadsum: number[] = [];
if (maxw === rw) {
for (let i = vbox.r1; i <= vbox.r2; i++) {
let sum = 0;
for (let j = vbox.g1; j <= vbox.g2; j++) {
for (let k = vbox.b1; k <= vbox.b2; k++) {
sum += histo[getColorIndex(i, j, k)] || 0;
}
}
total += sum;
partialsum[i] = total;
}
} else if (maxw === gw) {
for (let i = vbox.g1; i <= vbox.g2; i++) {
let sum = 0;
for (let j = vbox.r1; j <= vbox.r2; j++) {
for (let k = vbox.b1; k <= vbox.b2; k++) {
sum += histo[getColorIndex(j, i, k)] || 0;
}
}
total += sum;
partialsum[i] = total;
}
} else {
for (let i = vbox.b1; i <= vbox.b2; i++) {
let sum = 0;
for (let j = vbox.r1; j <= vbox.r2; j++) {
for (let k = vbox.g1; k <= vbox.g2; k++) {
sum += histo[getColorIndex(j, k, i)] || 0;
}
}
total += sum;
partialsum[i] = total;
}
}
partialsum.forEach((d, i) => {
lookaheadsum[i] = total - d;
});
function doCut(color: 'r' | 'g' | 'b'): [VBox, VBox] | undefined {
const dim1 = (color + '1') as 'r1' | 'g1' | 'b1';
const dim2 = (color + '2') as 'r2' | 'g2' | 'b2';
for (let i = vbox[dim1]; i <= vbox[dim2]; i++) {
if (partialsum[i] > total / 2) {
const vbox1 = vbox.copy();
const vbox2 = vbox.copy();
const left = i - vbox[dim1];
const right = vbox[dim2] - i;
let d2: number;
if (left <= right) {
d2 = Math.min(vbox[dim2] - 1, ~~(i + right / 2));
} else {
d2 = Math.max(vbox[dim1], ~~(i - 1 - left / 2));
}
// Avoid 0-count boxes
while (!partialsum[d2]) d2++;
let count2 = lookaheadsum[d2];
while (!count2 && partialsum[d2 - 1]) count2 = lookaheadsum[--d2];
// Set dimensions
vbox1[dim2] = d2;
vbox2[dim1] = vbox1[dim2] + 1;
return [vbox1, vbox2];
}
}
return undefined;
}
if (maxw === rw) return doCut('r');
if (maxw === gw) return doCut('g');
return doCut('b');
}
// ---------------------------------------------------------------------------
// Iterative splitting
// ---------------------------------------------------------------------------
function iterate(pq: PQueue<VBox>, target: number, histo: Uint32Array): void {
let ncolors = pq.size();
let niters = 0;
while (niters < MAX_ITERATIONS) {
if (ncolors >= target) return;
niters++;
const vbox = pq.pop();
if (!vbox.count()) {
pq.push(vbox);
continue;
}
const result = medianCutApply(histo, vbox);
if (!result || !result[0]) return;
pq.push(result[0]);
if (result[1]) {
pq.push(result[1]);
ncolors++;
}
}
}
// ---------------------------------------------------------------------------
// Main quantize function
// ---------------------------------------------------------------------------
function quantize(
pixels: Array<[number, number, number]>,
maxColors: number,
): Array<{ color: [number, number, number]; population: number }> {
if (!pixels.length || maxColors < 2 || maxColors > 256) return [];
// Short-circuit: if unique colors <= maxColors, return them directly
const seenColors = new Set<string>();
const uniqueColors: Array<[number, number, number]> = [];
for (const color of pixels) {
const key = color.join(',');
if (!seenColors.has(key)) {
seenColors.add(key);
uniqueColors.push(color);
}
}
if (uniqueColors.length <= maxColors) {
// Count populations for unique colors
const countMap = new Map<string, number>();
for (const color of pixels) {
const key = color.join(',');
countMap.set(key, (countMap.get(key) || 0) + 1);
}
return uniqueColors.map((color) => ({
color,
population: countMap.get(color.join(','))!,
}));
}
const histo = getHisto(pixels);
// Get the initial vbox from the pixels
const vbox = vboxFromPixels(pixels, histo);
const pq = new PQueue<VBox>((a, b) => a.count() - b.count());
pq.push(vbox);
// Phase 1: split by population until FRACT_BY_POPULATIONS * maxColors
iterate(pq, FRACT_BY_POPULATIONS * maxColors, histo);
// Phase 2: re-sort by count * volume, continue splitting
const pq2 = new PQueue<VBox>((a, b) => a.count() * a.volume() - b.count() * b.volume());
while (pq.size()) {
pq2.push(pq.pop());
}
iterate(pq2, maxColors, histo);
// Extract palette with population counts
const results: Array<{ color: [number, number, number]; population: number }> = [];
while (pq2.size()) {
const box = pq2.pop();
results.push({
color: box.avg(),
population: box.count(),
});
}
return results;
}
// ---------------------------------------------------------------------------
// Quantizer adapter
// ---------------------------------------------------------------------------
/**
* MMCQ (Modified Median Cut Quantization) — inlined TypeScript implementation.
* Port of the @lokesh.dhakar/quantize algorithm with population tracking.
*/
export class MmcqQuantizer implements Quantizer {
async init(): Promise<void> {
// No-op — pure TypeScript, ready to use.
}
quantize(
pixels: Array<[number, number, number]>,
maxColors: number,
): Array<{ color: [number, number, number]; population: number }> {
return quantize(pixels, maxColors);
}
}
================================================
FILE: src/quantizers/wasm.ts
================================================
import type { Quantizer } from '../types.js';
/**
* WASM-based MMCQ quantizer. Loads the WASM module built from Rust.
*
* Build the WASM module first:
* cd src/wasm && wasm-pack build --target web --out-dir ../../dist/wasm
*
* Usage:
* ```ts
* import { configure, WasmQuantizer } from 'colorthief';
* const q = new WasmQuantizer();
* await q.init();
* configure({ quantizer: q });
* ```
*/
export class WasmQuantizer implements Quantizer {
private wasmQuantize: ((pixels: Uint8Array, maxColors: number) => Uint8Array) | null = null;
private wasmUrl: string | URL | undefined;
/**
* @param wasmUrl - Optional URL to the .wasm file. If not provided,
* attempts to load from the default dist location.
*/
constructor(wasmUrl?: string | URL) {
this.wasmUrl = wasmUrl;
}
async init(): Promise<void> {
if (this.wasmQuantize) return;
// Try to dynamically import the wasm-bindgen generated JS glue
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any;
if (this.wasmUrl) {
// For environments where the wasm file needs explicit loading
const response = await fetch(this.wasmUrl);
const bytes = await response.arrayBuffer();
const module = await WebAssembly.compile(bytes);
const instance = await WebAssembly.instantiate(module);
wasm = instance.exports;
} else {
// Default: try importing the wasm-pack output
wasm = await import('../../dist/wasm/color_thief_wasm.js' as string);
if (wasm.default && typeof wasm.default === 'function') {
await wasm.default();
}
}
this.wasmQuantize = wasm.quantize;
} catch (e) {
throw new Error(
`Failed to initialize WASM quantizer: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
quantize(
pixels: Array<[number, number, number]>,
maxColors: number,
): Array<{ color: [number, number, number]; population: number }> {
if (!this.wasmQuantize) {
throw new Error('WasmQuantizer.init() must be called before quantize()');
}
// Flatten pixels into a flat Uint8Array [r,g,b,r,g,b,...]
const flat = new Uint8Array(pixels.length * 3);
for (let i = 0; i < pixels.length; i++) {
flat[i * 3] = pixels[i][0];
flat[i * 3 + 1] = pixels[i][1];
flat[i * 3 + 2] = pixels[i][2];
}
const resultBytes = this.wasmQuantize(flat, maxColors);
// Parse result: 7 bytes per color (r, g, b, pop_le[4])
const results: Array<{ color: [number, number, number]; population: number }> = [];
const view = new DataView(resultBytes.buffer, resultBytes.byteOffset, resultBytes.byteLength);
for (let offset = 0; offset + 6 < resultBytes.length; offset += 7) {
const r = resultBytes[offset];
const g = resultBytes[offset + 1];
const b = resultBytes[offset + 2];
const population = view.getUint32(offset + 3, true); // little-endian
results.push({ color: [r, g, b], population });
}
return results;
}
}
================================================
FILE: src/resolve-loader.browser.ts
================================================
import type { ImageSource, PixelLoader } from './types.js';
/**
* Resolve the default pixel loader — browser-only version.
* This module is substituted for resolve-loader.ts in browser builds
* so that bundlers never see the sharp dependency.
*/
export async function resolveDefaultLoader(): Promise<PixelLoader<ImageSource>> {
const { BrowserPixelLoader } = await import('./loaders/browser.js');
return new BrowserPixelLoader() as PixelLoader<ImageSource>;
}
================================================
FILE: src/resolve-loader.ts
================================================
import type { ImageSource, PixelLoader } from './types.js';
/**
* Resolve the default pixel loader based on the current environment.
* This universal version supports both browser and Node.js.
*
* The browser build swaps this module for resolve-loader.browser.ts
* which only includes the browser loader (no sharp dependency).
*/
export async function resolveDefaultLoader(): Promise<PixelLoader<ImageSource>> {
const isBrowser =
typeof window !== 'undefined' && typeof document !== 'undefined';
if (isBrowser) {
const { BrowserPixelLoader } = await import('./loaders/browser.js');
return new BrowserPixelLoader() as PixelLoader<ImageSource>;
} else {
const { NodePixelLoader } = await import('./loaders/node.js');
return new NodePixelLoader() as PixelLoader<ImageSource>;
}
}
================================================
FILE: src/swatches.ts
================================================
import type { Color, Swatch, SwatchMap, SwatchRole } from './types.js';
import { createColor } from './color.js';
// ---------------------------------------------------------------------------
// OKLCH target ranges for each swatch role
// ---------------------------------------------------------------------------
interface SwatchTarget {
role: SwatchRole;
/** Target OKLCH lightness (0–1). */
targetL: number;
/** Min / max lightness. */
minL: number;
maxL: number;
/** Target chroma (0–0.4). */
targetC: number;
/** Min chroma. */
minC: number;
}
const TARGETS: SwatchTarget[] = [
{ role: 'Vibrant', targetL: 0.65, minL: 0.40, maxL: 0.85, targetC: 0.20, minC: 0.08 },
{ role: 'Muted', targetL: 0.65, minL: 0.40, maxL: 0.85, targetC: 0.04, minC: 0.00 },
{ role: 'DarkVibrant', targetL: 0.30, minL: 0.00, maxL: 0.45, targetC: 0.20, minC: 0.08 },
{ role: 'DarkMuted', targetL: 0.30, minL: 0.00, maxL: 0.45, targetC: 0.04, minC: 0.00 },
{ role: 'LightVibrant', targetL: 0.85, minL: 0.70, maxL: 1.00, targetC: 0.20, minC: 0.08 },
{ role: 'LightMuted', targetL: 0.85, minL: 0.70, maxL: 1.00, targetC: 0.04, minC: 0.00 },
];
// ---------------------------------------------------------------------------
// Scoring
// ---------------------------------------------------------------------------
const WEIGHT_L = 6;
const WEIGHT_C = 3;
const WEIGHT_POP = 1;
function score(
color: Color,
target: SwatchTarget,
maxPopulation: number,
): number {
const { l, c } = color.oklch();
// Out of lightness range → disqualified
if (l < target.minL || l > target.maxL) return -Infinity;
// Below minimum chroma → disqualified
if (c < target.minC) return -Infinity;
const lDist = 1 - Math.abs(l - target.targetL);
const cDist = 1 - Math.min(Math.abs(c - target.targetC) / 0.2, 1);
const pop = maxPopulation > 0 ? color.population / maxPopulation : 0;
return lDist * WEIGHT_L + cDist * WEIGHT_C + pop * WEIGH
gitextract_gvnyy151/ ├── .editorconfig ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .mocharc.yml ├── .nvmrc ├── LICENSE ├── PLAN.md ├── README.md ├── V3.md ├── async.html ├── build/ │ └── build.js ├── cypress/ │ ├── e2e/ │ │ ├── api-direct.cy.js │ │ ├── api.cy.js │ │ ├── cors.cy.js │ │ └── module.cy.js │ ├── fixtures/ │ │ └── example.json │ ├── plugins/ │ │ └── index.cjs │ ├── support/ │ │ ├── commands.js │ │ └── e2e.js │ └── test-pages/ │ ├── api-direct.html │ ├── cors.html │ ├── es6-module.html │ ├── index.html │ ├── index.js │ └── screen.css ├── cypress.config.cjs ├── examples/ │ ├── css/ │ │ └── screen.css │ └── js/ │ └── demo.js ├── index.html ├── package.json ├── src/ │ ├── api.ts │ ├── cli.ts │ ├── color-space.ts │ ├── color.ts │ ├── index.ts │ ├── internals.browser.ts │ ├── internals.ts │ ├── loaders/ │ │ ├── browser.ts │ │ └── node.ts │ ├── observe.ts │ ├── pipeline.ts │ ├── progressive.ts │ ├── quantizers/ │ │ ├── mmcq.ts │ │ └── wasm.ts │ ├── resolve-loader.browser.ts │ ├── resolve-loader.ts │ ├── swatches.ts │ ├── sync.ts │ ├── types.ts │ ├── umd.ts │ ├── wasm/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ └── worker/ │ ├── manager.ts │ └── worker-script.ts ├── test/ │ ├── cli-test.js │ ├── node-cjs-test.cjs │ └── node-test.js ├── tsconfig.json └── tsup.config.ts
SYMBOL INDEX (183 symbols across 25 files)
FILE: cypress.config.cjs
method setupNodeEvents (line 7) | setupNodeEvents(on, config) {
FILE: cypress/e2e/api.cy.js
function rgbCount (line 1) | function rgbCount(text) {
function testPaletteCount (line 60) | function testPaletteCount(num) {
FILE: src/api.ts
function configure (line 35) | function configure(opts: {
function getLoader (line 47) | async function getLoader(perCall?: PixelLoader<ImageSource>): Promise<Pi...
function getQuantizer (line 54) | async function getQuantizer(perCall?: Quantizer): Promise<Quantizer> {
function checkAborted (line 70) | function checkAborted(signal?: AbortSignal): void {
function loadPixels (line 76) | async function loadPixels(
function getColor (line 97) | async function getColor(
function getPalette (line 116) | async function getPalette(
function getSwatches (line 167) | async function getSwatches(
FILE: src/cli.ts
constant HELP (line 18) | const HELP = `
type Command (line 43) | type Command = 'color' | 'palette' | 'swatches';
type CliArgs (line 45) | interface CliArgs {
function parseCliArgs (line 55) | function parseCliArgs(argv: string[]): CliArgs {
function readStdin (line 138) | async function readStdin(): Promise<Buffer> {
function ansiSwatch (line 152) | function ansiSwatch(hex: string): string {
function colorToJson (line 160) | function colorToJson(c: Color): Record<string, unknown> {
function swatchMapToJson (line 172) | function swatchMapToJson(swatches: SwatchMap): Record<string, unknown> {
function colorCss (line 185) | function colorCss(c: Color): string {
function paletteCss (line 189) | function paletteCss(colors: Color[]): string {
function swatchesCss (line 194) | function swatchesCss(swatches: SwatchMap): string {
function colorAnsi (line 208) | function colorAnsi(c: Color): string {
function paletteAnsi (line 212) | function paletteAnsi(colors: Color[]): string {
function swatchesAnsi (line 216) | function swatchesAnsi(swatches: SwatchMap): string {
function processFile (line 230) | async function processFile(
function formatResult (line 256) | function formatResult(
function main (line 298) | async function main(): Promise<void> {
FILE: src/color-space.ts
function srgbToLinear (line 8) | function srgbToLinear(c: number): number {
function linearToSrgb (line 14) | function linearToSrgb(c: number): number {
function rgbToOklch (line 24) | function rgbToOklch(r: number, g: number, b: number): OKLCH {
function oklchToRgb (line 58) | function oklchToRgb(l: number, c: number, h: number): [number, number, n...
function pixelsRgbToOklchScaled (line 92) | function pixelsRgbToOklchScaled(
function paletteOklchScaledToRgb (line 111) | function paletteOklchScaledToRgb(
FILE: src/color.ts
function rgbToHsl (line 8) | function rgbToHsl(r: number, g: number, b: number): HSL {
function relativeLuminance (line 39) | function relativeLuminance(r: number, g: number, b: number): number {
function contrastRatio (line 48) | function contrastRatio(l1: number, l2: number): number {
class ColorImpl (line 58) | class ColorImpl implements Color {
method constructor (line 70) | constructor(r: number, g: number, b: number, population: number, propo...
method rgb (line 78) | rgb(): RGB {
method hex (line 82) | hex(): string {
method hsl (line 87) | hsl(): HSL {
method oklch (line 94) | oklch(): OKLCH {
method css (line 101) | css(format: CssColorFormat = 'rgb'): string {
method array (line 117) | array(): [number, number, number] {
method toString (line 121) | toString(): string {
method textColor (line 125) | get textColor(): string {
method luminance (line 129) | private get luminance(): number {
method isDark (line 136) | get isDark(): boolean {
method isLight (line 140) | get isLight(): boolean {
method contrast (line 144) | get contrast(): ContrastInfo {
function createColor (line 167) | function createColor(
FILE: src/loaders/browser.ts
class BrowserPixelLoader (line 7) | class BrowserPixelLoader implements PixelLoader<BrowserSource> {
method load (line 8) | async load(source: BrowserSource): Promise<PixelData> {
method loadFromImage (line 36) | private loadFromImage(img: HTMLImageElement): PixelData {
method loadFromCanvas (line 67) | private loadFromCanvas(canvas: HTMLCanvasElement): PixelData {
method loadFromVideo (line 74) | private loadFromVideo(video: HTMLVideoElement): PixelData {
method loadFromOffscreenCanvas (line 96) | private loadFromOffscreenCanvas(canvas: OffscreenCanvas): PixelData {
method loadFromImageBitmap (line 108) | private loadFromImageBitmap(bitmap: ImageBitmap): PixelData {
FILE: src/loaders/node.ts
type NodeImageDecoder (line 4) | type NodeImageDecoder = (
type NodeLoaderOptions (line 8) | interface NodeLoaderOptions {
class NodePixelLoader (line 20) | class NodePixelLoader implements PixelLoader<NodeSource> {
method constructor (line 23) | constructor(options?: NodeLoaderOptions) {
method load (line 27) | async load(source: NodeSource): Promise<PixelData> {
function defaultSharpDecoder (line 38) | async function defaultSharpDecoder(
function createNodeLoader (line 61) | function createNodeLoader(options?: NodeLoaderOptions): NodePixelLoader {
FILE: src/observe.ts
type ObservableSource (line 18) | type ObservableSource =
type ObserveOptions (line 24) | interface ObserveOptions extends FilterOptions {
type ObserveController (line 38) | interface ObserveController {
function observe (line 69) | function observe(
FILE: src/pipeline.ts
type ValidatedOptions (line 18) | interface ValidatedOptions {
function validateOptions (line 28) | function validateOptions(options: ExtractionOptions): ValidatedOptions {
function createPixelArray (line 77) | function createPixelArray(
function computeFallbackColor (line 128) | function computeFallbackColor(
function extractPalette (line 159) | function extractPalette(
FILE: src/progressive.ts
constant PASSES (line 10) | const PASSES = [
function yieldToMain (line 17) | function yieldToMain(): Promise<void> {
FILE: src/quantizers/mmcq.ts
constant SIGBITS (line 7) | const SIGBITS = 5;
constant RSHIFT (line 8) | const RSHIFT = 8 - SIGBITS;
constant MAX_ITERATIONS (line 9) | const MAX_ITERATIONS = 1000;
constant FRACT_BY_POPULATIONS (line 10) | const FRACT_BY_POPULATIONS = 0.75;
constant HISTO_SIZE (line 11) | const HISTO_SIZE = 1 << (3 * SIGBITS);
function getColorIndex (line 17) | function getColorIndex(r: number, g: number, b: number): number {
class VBox (line 25) | class VBox {
method constructor (line 38) | constructor(
method volume (line 56) | volume(force = false): number {
method count (line 66) | count(force = false): number {
method copy (line 81) | copy(): VBox {
method avg (line 85) | avg(force = false): [number, number, number] {
class PQueue (line 137) | class PQueue<T> {
method constructor (line 141) | constructor(private comparator: (a: T, b: T) => number) {}
method sort (line 143) | private sort(): void {
method push (line 148) | push(item: T): void {
method peek (line 153) | peek(index?: number): T {
method pop (line 158) | pop(): T {
method size (line 163) | size(): number {
method map (line 167) | map<U>(fn: (item: T) => U): U[] {
function getHisto (line 176) | function getHisto(pixels: Array<[number, number, number]>): Uint32Array {
function vboxFromPixels (line 187) | function vboxFromPixels(
function medianCutApply (line 217) | function medianCutApply(histo: Uint32Array, vbox: VBox): [VBox, VBox | n...
function iterate (line 313) | function iterate(pq: PQueue<VBox>, target: number, histo: Uint32Array): ...
function quantize (line 343) | function quantize(
class MmcqQuantizer (line 410) | class MmcqQuantizer implements Quantizer {
method init (line 411) | async init(): Promise<void> {
method quantize (line 415) | quantize(
FILE: src/quantizers/wasm.ts
class WasmQuantizer (line 17) | class WasmQuantizer implements Quantizer {
method constructor (line 25) | constructor(wasmUrl?: string | URL) {
method init (line 29) | async init(): Promise<void> {
method quantize (line 58) | quantize(
FILE: src/resolve-loader.browser.ts
function resolveDefaultLoader (line 8) | async function resolveDefaultLoader(): Promise<PixelLoader<ImageSource>> {
FILE: src/resolve-loader.ts
function resolveDefaultLoader (line 10) | async function resolveDefaultLoader(): Promise<PixelLoader<ImageSource>> {
FILE: src/swatches.ts
type SwatchTarget (line 8) | interface SwatchTarget {
constant TARGETS (line 21) | const TARGETS: SwatchTarget[] = [
constant WEIGHT_L (line 34) | const WEIGHT_L = 6;
constant WEIGHT_C (line 35) | const WEIGHT_C = 3;
constant WEIGHT_POP (line 36) | const WEIGHT_POP = 1;
function score (line 38) | function score(
constant WHITE (line 61) | const WHITE = createColor(255, 255, 255, 0);
constant BLACK (line 62) | const BLACK = createColor(0, 0, 0, 0);
function textColors (line 64) | function textColors(color: Color): { title: Color; body: Color } {
function classifySwatches (line 76) | function classifySwatches(palette: Color[]): SwatchMap {
FILE: src/sync.ts
type SyncExtractionOptions (line 27) | interface SyncExtractionOptions extends FilterOptions {
function getColorSync (line 57) | function getColorSync(
function getPaletteSync (line 73) | function getPaletteSync(
function getSwatchesSync (line 102) | function getSwatchesSync(
function loadPixelsSync (line 114) | function loadPixelsSync(source: BrowserSource) {
function loadFromImage (line 138) | function loadFromImage(img: HTMLImageElement) {
function loadFromCanvas (line 169) | function loadFromCanvas(canvas: HTMLCanvasElement) {
function loadFromVideo (line 176) | function loadFromVideo(video: HTMLVideoElement) {
function loadFromOffscreenCanvas (line 198) | function loadFromOffscreenCanvas(canvas: OffscreenCanvas) {
function loadFromImageBitmap (line 210) | function loadFromImageBitmap(bitmap: ImageBitmap) {
FILE: src/types.ts
type RGB (line 6) | interface RGB {
type HSL (line 13) | interface HSL {
type OKLCH (line 20) | interface OKLCH {
type PixelBuffer (line 31) | type PixelBuffer = Uint8Array | Uint8ClampedArray;
type PixelData (line 34) | interface PixelData {
type FilterOptions (line 45) | interface FilterOptions {
type ColorSpace (line 57) | type ColorSpace = 'rgb' | 'oklch';
type ExtractionOptions (line 60) | interface ExtractionOptions extends FilterOptions {
type ContrastInfo (line 82) | interface ContrastInfo {
type CssColorFormat (line 92) | type CssColorFormat = 'rgb' | 'hsl' | 'oklch';
type Color (line 95) | interface Color {
type SwatchRole (line 128) | type SwatchRole =
type Swatch (line 137) | interface Swatch {
type SwatchMap (line 145) | type SwatchMap = Record<SwatchRole, Swatch | null>;
type PixelLoader (line 152) | interface PixelLoader<TSource> {
type Quantizer (line 158) | interface Quantizer {
type BrowserSource (line 173) | type BrowserSource =
type NodeSource (line 182) | type NodeSource = string | Buffer;
type ImageSource (line 185) | type ImageSource = BrowserSource | NodeSource;
type ProgressiveResult (line 192) | interface ProgressiveResult {
FILE: src/wasm/src/lib.rs
constant SIGBITS (line 7) | const SIGBITS: u32 = 5;
constant RSHIFT (line 8) | const RSHIFT: u32 = 8 - SIGBITS;
constant HIST_SIZE (line 9) | const HIST_SIZE: usize = 1 << (3 * SIGBITS);
constant MAX_ITERATIONS (line 10) | const MAX_ITERATIONS: usize = 1000;
constant FRACT_BY_POPULATION (line 11) | const FRACT_BY_POPULATION: f64 = 0.75;
function color_index (line 17) | fn color_index(r: u32, g: u32, b: u32) -> usize {
function build_histogram (line 21) | fn build_histogram(pixels: &[u8]) -> (Vec<u32>, usize) {
type VBox (line 40) | struct VBox {
method from_pixels (line 52) | fn from_pixels(pixels: &[u8]) -> Self {
method update_count (line 80) | fn update_count(&mut self, hist: &[u32]) {
method update_volume (line 92) | fn update_volume(&mut self) {
method avg_color (line 98) | fn avg_color(&self, hist: &[u32]) -> (u8, u8, u8, u32) {
method widest_dimension (line 133) | fn widest_dimension(&self) -> u8 {
function median_cut (line 147) | fn median_cut(hist: &[u32], vbox: &VBox) -> Option<(VBox, VBox)> {
function iterate (line 233) | fn iterate(
function quantize (line 292) | pub fn quantize(pixels: &[u8], max_colors: usize) -> Vec<u8> {
function test_single_color (line 333) | fn test_single_color() {
function test_two_colors (line 345) | fn test_two_colors() {
function test_empty_input (line 355) | fn test_empty_input() {
FILE: src/worker/manager.ts
function isWorkerSupported (line 14) | function isWorkerSupported(): boolean {
function getOrCreateWorker (line 18) | function getOrCreateWorker(): Worker {
function extractInWorker (line 58) | function extractInWorker(
function terminateWorker (line 91) | function terminateWorker(): void {
FILE: src/worker/worker-script.ts
constant WORKER_SOURCE (line 13) | const WORKER_SOURCE = /* js */ `
FILE: test/cli-test.js
function run (line 14) | function run(...args) {
function runWithStdin (line 18) | function runWithStdin(filePath, ...args) {
FILE: test/node-cjs-test.cjs
function isColorObject (line 14) | function isColorObject(c) {
FILE: test/node-test.js
function isColorObject (line 18) | function isColorObject(c) {
function isValidRGB (line 34) | function isValidRGB(color) {
function isCloseTo (line 39) | function isCloseTo(color, expected, tolerance = 15) {
FILE: tsup.config.ts
method setup (line 11) | setup(build) {
method esbuildOptions (line 65) | esbuildOptions(options) {
Condensed preview — 59 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (276K chars).
[
{
"path": ".editorconfig",
"chars": 372,
"preview": "# EditorConfig helps developers define and maintain consistent\n# coding styles between different editors and IDEs\n# edit"
},
{
"path": ".github/workflows/ci.yml",
"chars": 832,
"preview": "name: CI\n\non:\n push:\n branches: [master, dev]\n pull_request:\n branches: [master]\n\njobs:\n node-tests:\n runs-o"
},
{
"path": ".gitignore",
"chars": 178,
"preview": "*.log\n*.sql\n*.sqlite\n.htaccess\n.ftppass\n.host_config\n\n*.DS_Store\nehthumbs.db\nIcon?\nThumbs.db\n\n.sass-cache\nRakefile\nrsync"
},
{
"path": ".mocharc.yml",
"chars": 40,
"preview": "spec: test/**/*.{js,cjs}\ntimeout: 10000\n"
},
{
"path": ".nvmrc",
"chars": 7,
"preview": "18.20.4"
},
{
"path": "LICENSE",
"chars": 1080,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2015 Lokesh Dhakar\n\nPermission is hereby granted, free of charge, to any person obt"
},
{
"path": "PLAN.md",
"chars": 5926,
"preview": "# PLAN.md\n\n## Phase 1: v2 — Non-breaking improvements\n\nImprovements that ship under the current API contract. No breakin"
},
{
"path": "README.md",
"chars": 7468,
"preview": "# Color Thief\n\n> Extract dominant colors and palettes from images in the browser and Node.js.\n\n[;\nconst { resolve } = require('path');\n\n/*\ncolor-thief.umd.js duplicated as color-thief.min.js for"
},
{
"path": "cypress/e2e/api-direct.cy.js",
"chars": 7182,
"preview": "describe('Direct API - getColorSync()', { testIsolation: false }, function() {\n before(function() {\n cy.visit("
},
{
"path": "cypress/e2e/api.cy.js",
"chars": 2598,
"preview": "function rgbCount(text) {\n const vals = text.split(',');\n for (const val of vals) {\n if (val < 0 || val > 2"
},
{
"path": "cypress/e2e/cors.cy.js",
"chars": 331,
"preview": "describe('cross domain images with liberal CORS policy', function() {\n it('load', function() {\n cy.visit('http"
},
{
"path": "cypress/e2e/module.cy.js",
"chars": 304,
"preview": "describe('es6 module', function() {\n it('loads', function() {\n cy.visit('http://localhost:8080/cypress/test-pa"
},
{
"path": "cypress/fixtures/example.json",
"chars": 154,
"preview": "{\n \"name\": \"Using fixtures to represent data\",\n \"email\": \"hello@cypress.io\",\n \"body\": \"Fixtures are a great way to mo"
},
{
"path": "cypress/plugins/index.cjs",
"chars": 644,
"preview": "// ***********************************************************\n// This example plugins/index.js can be used to load plug"
},
{
"path": "cypress/support/commands.js",
"chars": 841,
"preview": "// ***********************************************\n// This example commands.js shows you how to\n// create various custom"
},
{
"path": "cypress/support/e2e.js",
"chars": 670,
"preview": "// ***********************************************************\n// This example support/index.js is processed and\n// load"
},
{
"path": "cypress/test-pages/api-direct.html",
"chars": 1201,
"preview": "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <title>Color Thief - Direct API Tests</title>\n</h"
},
{
"path": "cypress/test-pages/cors.html",
"chars": 954,
"preview": "<!doctype html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"utf-8\">\n\t<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chr"
},
{
"path": "cypress/test-pages/es6-module.html",
"chars": 882,
"preview": "<!doctype html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"utf-8\">\n\t<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chr"
},
{
"path": "cypress/test-pages/index.html",
"chars": 1763,
"preview": "<!doctype html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"utf-8\">\n\t<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chr"
},
{
"path": "cypress/test-pages/index.js",
"chars": 1984,
"preview": "var images = [\n 'black.png',\n 'red.png',\n 'rainbow-horizontal.png',\n 'rainbow-vertical.png',\n 'transparen"
},
{
"path": "cypress/test-pages/screen.css",
"chars": 1754,
"preview": ":root {\n /* Colors */\n --color: #000;\n --bg-color: #f9f9f9;\n --primary-color: #fc4c02;\n --secondary-color"
},
{
"path": "cypress.config.cjs",
"chars": 351,
"preview": "const { defineConfig } = require('cypress')\n\nmodule.exports = defineConfig({\n e2e: {\n // We've imported your old cyp"
},
{
"path": "examples/css/screen.css",
"chars": 1778,
"preview": ":root {\n /* Colors */\n --color: #000;\n --bg-color: #f9f9f9;\n --primary-color: #fc4c02;\n --secondary-color"
},
{
"path": "examples/js/demo.js",
"chars": 1734,
"preview": "var colorThief = new ColorThief();\n\nvar images = [\n 'image-1.jpg',\n 'image-2.jpg',\n 'image-3.jpg',\n];\n\n// Rende"
},
{
"path": "index.html",
"chars": 51121,
"preview": "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width"
},
{
"path": "package.json",
"chars": 3302,
"preview": "{\n \"name\": \"colorthief\",\n \"version\": \"3.3.1\",\n \"type\": \"module\",\n \"author\": {\n \"name\": \"Lokesh Dhakar"
},
{
"path": "src/api.ts",
"chars": 5893,
"preview": "import type {\n Color,\n ExtractionOptions,\n ImageSource,\n PixelData,\n PixelLoader,\n ProgressiveResult,\n"
},
{
"path": "src/cli.ts",
"chars": 11312,
"preview": "import { parseArgs } from 'node:util';\nimport { createRequire } from 'node:module';\nimport { readFileSync } from 'node:f"
},
{
"path": "src/color-space.ts",
"chars": 4505,
"preview": "import type { OKLCH } from './types.js';\n\n// ---------------------------------------------------------------------------"
},
{
"path": "src/color.ts",
"chars": 5151,
"preview": "import type { RGB, HSL, OKLCH, Color, ContrastInfo, CssColorFormat } from './types.js';\nimport { rgbToOklch } from './co"
},
{
"path": "src/index.ts",
"chars": 1610,
"preview": "// ---------------------------------------------------------------------------\n// Public API\n// ------------------------"
},
{
"path": "src/internals.browser.ts",
"chars": 1229,
"preview": "// ---------------------------------------------------------------------------\n// colorthief/internals (browser build)\n/"
},
{
"path": "src/internals.ts",
"chars": 1341,
"preview": "// ---------------------------------------------------------------------------\n// colorthief/internals\n//\n// Power-user "
},
{
"path": "src/loaders/browser.ts",
"chars": 4847,
"preview": "import type { BrowserSource, PixelData, PixelLoader } from '../types.js';\n\n/**\n * Browser pixel loader. Extracts RGBA pi"
},
{
"path": "src/loaders/node.ts",
"chars": 2265,
"preview": "import type { NodeSource, PixelData, PixelLoader } from '../types.js';\n\n/** Custom decoder signature for pluggable Node "
},
{
"path": "src/observe.ts",
"chars": 5893,
"preview": "/**\n * Live extraction mode — observe() with reactive updates.\n *\n * Watches a source element (video, canvas, or img) an"
},
{
"path": "src/pipeline.ts",
"chars": 6302,
"preview": "import type {\n Color,\n ExtractionOptions,\n FilterOptions,\n PixelBuffer,\n Quantizer,\n} from './types.js';\n"
},
{
"path": "src/progressive.ts",
"chars": 1533,
"preview": "import type {\n Color,\n PixelBuffer,\n ProgressiveResult,\n Quantizer,\n} from './types.js';\nimport { extractPal"
},
{
"path": "src/quantizers/mmcq.ts",
"chars": 13050,
"preview": "import type { Quantizer } from '../types.js';\n\n// ----------------------------------------------------------------------"
},
{
"path": "src/quantizers/wasm.ts",
"chars": 3394,
"preview": "import type { Quantizer } from '../types.js';\n\n/**\n * WASM-based MMCQ quantizer. Loads the WASM module built from Rust.\n"
},
{
"path": "src/resolve-loader.browser.ts",
"chars": 473,
"preview": "import type { ImageSource, PixelLoader } from './types.js';\n\n/**\n * Resolve the default pixel loader — browser-only vers"
},
{
"path": "src/resolve-loader.ts",
"chars": 841,
"preview": "import type { ImageSource, PixelLoader } from './types.js';\n\n/**\n * Resolve the default pixel loader based on the curren"
},
{
"path": "src/swatches.ts",
"chars": 5479,
"preview": "import type { Color, Swatch, SwatchMap, SwatchRole } from './types.js';\nimport { createColor } from './color.js';\n\n// --"
},
{
"path": "src/sync.ts",
"chars": 7730,
"preview": "/**\n * Synchronous browser-only API.\n *\n * These functions accept only BrowserSource (HTMLImageElement, HTMLCanvasElemen"
},
{
"path": "src/types.ts",
"chars": 6605,
"preview": "// ---------------------------------------------------------------------------\n// Color spaces\n// ----------------------"
},
{
"path": "src/umd.ts",
"chars": 693,
"preview": "/**\n * UMD entry point — exposes ColorThief as a global with function-based API.\n *\n * Usage in a <script> tag:\n * con"
},
{
"path": "src/wasm/Cargo.toml",
"chars": 201,
"preview": "[package]\nname = \"color-thief-wasm\"\nversion = \"0.1.0\"\nedition = \"2021\"\npublish = false\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n["
},
{
"path": "src/wasm/src/lib.rs",
"chars": 10699,
"preview": "use wasm_bindgen::prelude::*;\n\n// ---------------------------------------------------------------------------\n// Constan"
},
{
"path": "src/worker/manager.ts",
"chars": 3291,
"preview": "import type { Color } from '../types.js';\nimport { createColor } from '../color.js';\nimport { WORKER_SOURCE } from './wo"
},
{
"path": "src/worker/worker-script.ts",
"chars": 8094,
"preview": "/**\n * Self-contained worker script that receives pixel data, runs quantization,\n * and returns the serialized palette.\n"
},
{
"path": "test/cli-test.js",
"chars": 8621,
"preview": "import { resolve } from 'path';\nimport { execFile } from 'child_process';\nimport { readFileSync } from 'fs';\nimport { pr"
},
{
"path": "test/node-cjs-test.cjs",
"chars": 1382,
"preview": "const { resolve } = require('path');\nconst { readFileSync } = require('fs');\nconst { getColor, getPalette, createColor }"
},
{
"path": "test/node-test.js",
"chars": 15941,
"preview": "import { resolve } from 'path';\nimport { readFileSync } from 'fs';\nimport { getColor, getPalette, getSwatches, getPalett"
},
{
"path": "tsconfig.json",
"chars": 663,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2020\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\""
},
{
"path": "tsup.config.ts",
"chars": 3016,
"preview": "import { defineConfig } from 'tsup';\nimport type { Plugin } from 'esbuild';\nimport path from 'path';\n\n/**\n * esbuild plu"
}
]
About this extraction
This page contains the full source code of the lokesh/color-thief GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 59 files (258.3 KB), approximately 63.4k tokens, and a symbol index with 183 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.