I created a web application called “Pokémon Palette” that allows you to generate Pokémon-themed color icons
I built and published Pokémon Palette, a web app that extracts the colours actually used in a Pokémon’s artwork and turns them into an icon. It covers National Dex 001-809, needs no account, and works in a phone browser.
This article was published in 2020 and completely rewritten in September 2026. The original build combined Vue with three colour-extraction libraries (Vibrant.js, color-thief and RGBaster) and rasterised the DOM with html2canvas. The current version uses no external libraries at all — both the colour extraction and the icon rendering are written from scratch.
This article covers why I rebuilt it and how the colour extraction works.
What Pokémon Palette does
Pick a Pokémon and the colours used in its artwork are listed largest-area-first, then turned into an icon you can export as PNG.
| Coverage | National Dex 001-809 (809 species) |
|---|---|
| Extracted colours | Up to 8, ordered by area. Each shows its share (%), plus the palette’s total coverage |
| Icon styles | 8 (gradient, mesh, bars, orb, arcs, wave, ring, stripes) |
| Icon shape | Square / rounded / circle |
| Export | PNG at 256, 512 or 1024 px |
| Copy | HEX list, or CSS custom properties |
| Other | PWA (installable, works offline), no account, free |
The stack is HTML, CSS and JavaScript only. No build step and no dependencies.
Why I rebuilt it
The original used three colour libraries at once: Vibrant.js, color-thief and RGBaster. The reasoning at the time was “one library returns too few colours, so use more”.
That produced two structural problems.
| Problem | Consequence |
|---|---|
| Three results merge asynchronously | Colours get dropped, and the output changes with timing |
| No guarantee the largest-area colour ranks first | The most widely used colour can be missed entirely — in an app about image colour |
The second one was fatal. The point of the app is the Pokémon’s image colour, and what these libraries return is “characteristic” colours, not area-ordered ones.
So I switched to treating the actual pixel count as the primary source of truth, always returning a palette ordered by area. Counting the pixels myself turned out to be the straightforward answer.
How the extraction works
The image is drawn to a canvas and the pixels are counted directly, in four stages.
1. Discard the transparent fringe
Pixels with an alpha below 200 are ignored. The outline of a transparent PNG is a semi-transparent bleed, and counting it introduces intermediate colours that are not in the artwork.
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < alphaThreshold) continue; // alphaThreshold = 200
// ...
}
Downscaling also has smoothing turned off, so sampling is nearest-neighbour. Interpolation likewise invents colours that were never in the original.
scratchCtx.imageSmoothingEnabled = false;
scratchCtx.drawImage(image, 0, 0, w, h);
2. Build a histogram that stores mean colours, not bin centres
Each RGB channel is quantised to 5 bits (32 levels), giving 32 x 32 x 32 = 32,768 bins.
One detail matters here: the representative colour is not the bin centre but the actual mean of the pixels that landed in it.
const key = ((r >> SHIFT) << (BITS * 2)) | ((g >> SHIFT) << BITS) | (b >> SHIFT);
counts[key]++;
sumR[key] += r; // keep the sums, take the mean later
sumG[key] += g;
sumB[key] += b;
Using bin centres carries the quantisation error straight into the output. Taking the mean keeps a value close to the original even at 32 levels.
3. Merge greedily in CIE L*a*b*
Bins are scanned in descending order of pixel count. If a bin is within the threshold distance of an existing cluster it is merged; otherwise it starts a new one.
The distance is computed in CIE L\*a\*b\*, not RGB. Distances in RGB do not correspond to perceived difference, so judging “close” in RGB merges colours that look distinct.
// threshold is a CIE76 delta-E of 11
const thr2 = mergeThreshold * mergeThreshold;
for (const bin of bins) {
// find the nearest cluster
if (best >= 0 && (bestD < thr2 || clusters.length >= maxClusters)) {
// merge and update the centroid
} else {
clusters.push({ ... }); // new cluster
}
}
Processing in descending order is the key. The largest-area colour always becomes the seed of a cluster, which guarantees that the most widely used colour appears first in the output.
4. Sort by area and return the top 8
Clusters that are still too close are merged once more, then sorted by area. Each colour carries its share of the image, and the sum of the selected colours is the palette’s coverage.
Colours below 0.5% are dropped, but at least three are always kept — otherwise a near-monochrome Pokémon would produce a single-colour palette.
Blending happens in Oklab
This was the single most effective change for the icon gradients.
The canvas createLinearGradient interpolates in sRGB. Connect two hues that are far apart and the middle goes muddy — orange to teal turns brown on the way.
So every two-colour interpolation happens in Oklab, with fine stops written out manually.
function smoothGradient(gradient, stops) {
const STEPS = 14;
for (let i = 0; i < stops.length - 1; i++) {
const a = stops[i];
const b = stops[i + 1];
for (let k = 0; k <= STEPS; k++) {
const t = k / STEPS;
gradient.addColorStop(a.at + (b.at - a.at) * t, mix(a.color, b.color, t));
}
}
return gradient;
}
The browser’s own gradient interpolation is not reachable, so the workaround is to place the intermediate colours yourself. Fourteen subdivisions is enough to read as continuous.
How the icon’s four colours are chosen
Taking the top four by area alone produces a dull icon. Pokémon artwork has black outlines and white highlights, so pure area ranking puts black and white in the lead.
Instead, the lead colour is chosen by a combined area-and-chroma score.
const score = (c) => {
const area = Math.sqrt(c.ratio); // area, damped
const vivid = 0.3 + 0.7 * Math.min(1, c.chroma / 55); // chroma
const lightness = c.lab[0];
const extreme = lightness > 93 || lightness < 12 ? 0.45 : 1; // penalise blown/crushed
return area * vivid * extreme;
};
From the second colour onward, the score is multiplied by the distance from the colours already chosen, so the four do not end up near-identical.
It still will not always match taste, so a “shuffle” button reselects four colours at random from the extracted palette.
Dropping html2canvas for direct canvas drawing
The original assembled DOM and rasterised it with html2canvas, which imposed two limits:
- Expression was restricted to what CSS can build
- Export resolution could not be raised
Drawing straight to canvas means exporting at any size without degradation — which is why 256, 512 and 1024 px are all available.
Waves, radial blurs and rings — awkward in CSS — became straightforward to write.
Why it is a PWA, and how caching works
Add it to your home screen and it behaves like an app. Any Pokémon you have already viewed still extracts colours and generates icons offline.
But the images total nearly 120 MB, so precaching everything is out. The Service Worker splits strategy by target.
| Target | Strategy |
|---|---|
| HTML | Network first, so updates are not missed |
| CSS / JS / icons | stale-while-revalidate |
| Pokémon images | Cache first, storing only what has been shown, evicting oldest past 400 entries |
One more trap. Caching sw.js like any other JS means an old Service Worker keeps squatting even after you ship an update. The .htaccess sets Cache-Control: no-cache on sw.js alone to avoid it.
Two things that caught me out
Opening with file:// makes the colours unreadable
Double-clicking index.html locally taints the canvas and makes getImageData() throw, because it is not treated as same-origin.
python3 -m http.server 8000
# then open http://localhost:8000/
The app catches that exception and tells you to serve it over HTTP instead of failing silently.
Linux is case-sensitive about filenames
Sprite filenames contain uppercase, as in 001MS.png. It works locally on macOS and 404s on the server if uploaded in lowercase.
Summary
- Pokémon Palette extracts colours by area from 809 species and exports icons as PNG, free
- The original’s flaw was no guarantee the largest-area colour ranked first. Three libraries were dropped for a hand-written implementation
- Extraction counts canvas pixels directly, discarding the transparent fringe below alpha 200
- The histogram stores actual mean colours, not bin centres, so quantisation error stays out
- Merging is judged by CIE L\*a\*b\* distance, not RGB
- Processing in descending count order guarantees the largest colour comes first
- Blending happens in Oklab; sRGB muddies the midpoint
- The icon’s four colours come from an area-times-chroma score — area alone gives you black and white
- Dropping html2canvas for direct canvas drawing allows clean export up to 1024 px
- The PWA caches images with a 400-entry LRU, and never caches
sw.js
The real gain from removing the libraries was being able to trace why a colour came out wrong myself.
Pokémon and its images and names are the property of The Pokémon Company and Nintendo. This is an unofficial, fan-made tool.