Overview
Color Palette Detector is a Manifest V3 Chrome extension that extracts, analyzes, and exports color palettes from any image or any website you're viewing. It runs entirely in the browser — no backend, no accounts, no telemetry — and presents itself as a dark-mode-first side panel that re-tints its own accent to the palette you just pulled.
I built it as a self-contained product and a showcase piece: a real MV3 extension with the four execution contexts wired together properly, hand-rolled color math, and a test suite (unit + end-to-end) rather than a toy.
The problem
Designers and front-end engineers constantly need to answer "what colors is this?" — from a reference image, a competitor's site, or their own running app — and then get those colors out in a usable format. Most tools either only accept an uploaded image, require a paid account, or phone home with what you feed them.
I wanted one tool that could read colors from a live page (including colors set by JavaScript at runtime, which a static CSS scan misses), quantify the dominant palette honestly, check contrast against WCAG, and export to the formats people actually paste into code and design tools — all without a single network request that isn't the user's own action.
Constraints
- Manifest V3. No long-lived background page; the service worker is ephemeral and has no DOM or Canvas, so pixel work cannot live there.
- Client-side only. No server to offload image decoding or quantization to — it has to be fast on the main thread's budget, so heavy work goes to a worker.
- Cross-origin images. Loading a pasted image URL through a normal
<img>taints the canvas and blocksgetImageData; the bytes have to be fetched another way. - Privacy as a feature. The scanner must never run on pages automatically, and nothing may be logged or sent anywhere.
Architecture
An MV3 extension runs code in isolated contexts. This one uses four, each with a single job — the side panel orchestrates, the worker does the pixel math, the service worker handles privileged fetches, and the scanner is injected only on demand.
┌─────────────────┐ message ┌────────────────────┐
│ Content script │◄────────────────►│ Service worker │
│ (DOM scanner) │ on-demand only │ (bg · image fetch) │
└─────────────────┘ └─────────┬──────────┘
injected via │ message
executeScript({func}) │
┌──────────▼──────────┐
│ Side panel │
│ React + Zustand │
│ spawns │
│ ┌──────────────┐ │
│ │ Web worker │ │
│ │ median-cut, │ │
│ │OffscreenCanvas│ │
│ └──────────────┘ │
└─────────────────────┘
upload / drag-drop → ArrayBuffer ─────────────► worker → palette
image URL → SW fetch → ArrayBuffer (base64 hop) ► worker → palette
scan page → executeScript → ScanRaw → normalize (culori) → roles
- Service worker fetches cross-origin image URLs to an
ArrayBuffer, sidestepping the tainted-canvas problem because the image never loads through a cross-origin<img>. It also opens the side panel on the toolbar click. - Content script walks the DOM, reads
getComputedStylefor every element, and aggregates colors weighted by on-screen area — catching runtime colors a CSS-only scan would miss. It is injected on demand viachrome.scripting.executeScript, so it never runs on a page you didn't ask it to scan. - Side panel (React + Zustand) is the UI. A side panel, not a popup, so it stays open while you browse a site and manipulate its colors side-by-side.
- Web worker decodes the image with
createImageBitmap→OffscreenCanvas, downsamples, and runs median-cut quantization — all off the main thread.
Every color is normalized to one Color type (hex / rgb / hsl / cmyk) at the
point of entry, so the UI never juggles raw color strings.
Key decisions & trade-offs
- Side panel over popup. A popup dies the instant you click the page — fatal
for a "scan this site, then work with its colors" flow. The side panel
(
chrome.sidePanel) stays put. - Inject the scanner, don't declare it. Keeping the content script out of the manifest means zero always-on page access. The cost: the injected function is serialized and re-parsed in the page, so it must be fully self-contained — every helper nested inside it, no module imports at runtime. Worth it for the privacy guarantee.
- Fetch image bytes in the service worker. The clean fix for tainted canvases,
with one MV3 wrinkle: an
ArrayBuffercan't cross the message boundary intact, so the bytes make a base64 hop back to the panel. - Hand-implemented median cut and the Adobe
.asebinary writer. No heavyweight color library for the core — it keeps the bundle small and the behavior exactly what I want.culoriis used only for color-space conversion. - Honest labels. CMYK is marked approximate (no ICC profile), and website role labels (background / text / primary) are presented as best-effort heuristics, not guarantees.
Status & what's next
The extension is built and working — load the dist/ folder unpacked in
Chrome and the full flow runs: upload or scan → dominant colors (with 60-30-10
coverage) → harmony generators → WCAG contrast badges → export to JPEG, CSS
variables, Adobe .ase, Markdown, and a self-contained HTML report. It's covered
by a Vitest unit suite over the color logic and scanner plus a Playwright
end-to-end test that drives the real built extension.
It is not yet on the Chrome Web Store — publishing requires a paid developer
account, which I haven't set up yet. Before listing, the one change I'd make is
migrating <all_urls> (a dev-mode convenience for the on-demand fetch/scanner)
to optional_host_permissions requested per-action, so the store listing asks
for the narrowest possible permissions.
The full source is public on GitHub:
github.com/frankydinh/color-palette-detector
— clone it, run npm install && npm run build, and load dist/ unpacked.
Free and open source while the Web Store listing is pending.