Package Exports
- palette-shader
Readme
palette-shader
A dependency-free WebGL2 shader that maps any colour palette across a 3-D perceptual colour space and snaps each pixel to the nearest palette colour. Visualise how a palette distributes across HSV, HSL, LCH or their perceptual OK-variants, and compare results across six colour-distance metrics — all on the GPU.
What is this for?
It shows you how a color palette distributes across "all possible colors." Each region of the wheel or grid represents a color — and whichever palette color is closest to it claims that region.
So if one of your palette colors only claims a tiny sliver, it lives very close to another color already in your palette — it's almost redundant. If it claims a large region, it's doing a lot of unique work. At a glance you can tell:
- How distinct each color is from the others
- How balanced the palette is overall — even regions mean even coverage
- Whether a new color is worth adding — if it doesn't carve out its own space, it's probably not pulling its weight
Install
npm install palette-shaderNo dependencies — only a browser with WebGL support is required.
Quick start
import { PaletteViz } from 'palette-shader';
// option A — pass a container, canvas is appended automatically
const viz = new PaletteViz({
palette: ['#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'],
container: document.querySelector('#app'),
width: 512,
height: 512,
});
// option B — no container, place the canvas yourself
const viz = new PaletteViz({ palette: ['#264653', '#2a9d8f', '#e9c46a'] });
document.querySelector('#app').appendChild(viz.canvas);Constructor
new PaletteViz(options?: PaletteVizOptions)All options are optional. The palette defaults to a random 20-colour set.
| Option | Type | Default | Description |
|---|---|---|---|
palette |
string[] |
random | CSS colour strings (#hex, rgb(), hsl(), …) |
container |
HTMLElement |
undefined |
Element the canvas is appended to. Omit and use viz.canvas to place it yourself |
width |
number |
512 |
Canvas width in CSS pixels |
height |
number |
512 |
Canvas height in CSS pixels |
pixelRatio |
number |
devicePixelRatio |
Renderer pixel ratio |
colorModel |
string |
'okhsv' |
Colour space for the visualisation (see Colour models) |
distanceMetric |
string |
'oklab' |
Distance function for nearest-colour matching (see Distance metrics) |
axis |
'x' | 'y' | 'z' |
'y' |
Which axis the position value controls |
position |
number |
0 |
0–1 position along the chosen axis |
invertLightness |
boolean |
false |
Flip the lightness/value axis |
showRaw |
boolean |
false |
Bypass nearest-colour matching (shows the raw colour space) |
Properties
Every constructor option is also a live setter/getter. Assigning any of them re-renders immediately via requestAnimationFrame.
viz.palette = ['#ff0000', '#00ff00', '#0000ff'];
viz.position = 0.5;
viz.colorModel = 'okhslPolar';
viz.distanceMetric = 'deltaE2000';
viz.invertLightness = true;
viz.showRaw = true;Additional read-only properties:
| Property | Type | Description |
|---|---|---|
canvas |
HTMLCanvasElement |
The underlying canvas element |
width |
number |
Current width in CSS pixels |
height |
number |
Current height in CSS pixels |
Methods
resize(width, height?)
Resize the canvas. If height is omitted the canvas stays square.
window.addEventListener('resize', () => viz.resize(window.innerWidth * 0.5));setColor(color, index)
Update a single palette entry without rebuilding the whole texture.
viz.setColor('#e63946', 2);addColor(color, index?)
Insert a colour at index (appends if omitted).
viz.addColor('#a8dadc'); // append
viz.addColor('#457b9d', 0); // prependremoveColor(index | color)
Remove a palette entry by index or by colour string.
viz.removeColor(0);
viz.removeColor('#a8dadc');destroy()
Cancel the animation frame, release all WebGL resources (program, texture, buffer, VAO), and remove the canvas from the DOM.
Colour models
Controls the 3-D colour space the visualisation is rendered in. Polar variants (*Polar) map hue to angle and show a circular wheel; non-polar variants show a rectangular slice.
| Value | Shape | Description |
|---|---|---|
'okhsv' |
cube | Default. Hue–Saturation–Value built on OKLab. Gamut-aware with perceptually uniform saturation steps. |
'okhsvPolar' |
wheel | Polar (cylindrical) form of OKHsv. |
'okhsl' |
cube | Hue–Saturation–Lightness built on OKLab. Better lightness uniformity across hues. |
'okhslPolar' |
wheel | Polar form of OKHsl. |
'oklch' |
cube | OKLab in cylindrical coordinates (L, C, h). Ideal for chroma or lightness slices. |
'oklchPolar' |
wheel | Polar form of OKLch. |
'hsv' |
cube | Classic HSV. Not perceptually uniform, but familiar and fast. |
'hsvPolar' |
wheel | Polar form of HSV. |
'hsl' |
cube | Classic HSL. Same caveats as 'hsv'. |
'hslPolar' |
wheel | Polar form of HSL. |
'oklab' |
cube | Raw OKLab cube: x→a, y→b, z→L. Cube only — no polar variant. |
'rgb' |
cube | Raw sRGB cube. Useful as a baseline. Cube only — no polar variant. |
The OK-variants rely on Björn Ottosson's gamut-aware implementation and produce significantly more even hue distributions than the classic variants at the same GPU cost.
Cube vs. polar — which to use?
Both shapes render the same underlying colour space; they just arrange it differently on screen.
Cube (rectangular slice) lays the three axes out as a flat grid. One axis is fixed by the position slider, the other two fill the canvas. This makes it easy to read absolute values — you can see exactly where on the hue, saturation and lightness axes each palette colour falls, and compare palettes side-by-side without any projection distortion.
Polar (wheel) wraps the hue axis around a circle. Hue runs around the circumference, saturation (or chroma) runs outward from the centre, and the third axis is controlled by position. This matches the intuition most designers have for colour — it's immediately obvious whether two colours are complementary, analogous or triadic. Voronoi regions that are nearly circular indicate a well-balanced palette; lopsided regions reveal hue bias.
A practical starting point: use a polar model to get an intuitive read on hue distribution and harmony, then switch to a cube slice to inspect individual lightness or saturation bands in detail. rgb and oklab have no polar variant because they aren't hue-based cylindrical spaces.
Distance metrics
Controls how "nearest palette colour" is determined per pixel.
| Value | Description | Cost |
|---|---|---|
'oklab' |
Default. Euclidean distance in OKLab. Fast, perceptually uniform, excellent general-purpose choice. | low |
'kotsarenkoRamos' |
Weighted Euclidean in sRGB — no colour-space conversion. Weights R and B by the mean red value for quick perceptual improvement over plain RGB. | lowest |
'deltaE76' |
CIE 1976: plain Euclidean distance in CIELab. Classic standard, decent uniformity. | medium |
'deltaE94' |
CIE 1994: adds chroma and hue weighting on top of ΔE76. Better than ΔE76, cheaper than ΔE2000. | medium |
'deltaE2000' |
CIEDE2000: weighted colour difference with per-channel corrections for hue, chroma, and lightness. Most accurate, most expensive. | high |
'rgb' |
Plain Euclidean in sRGB. Not perceptually uniform. Useful as a baseline. | lowest |
Advanced usage
Accessing the canvas
// with no container, manage placement yourself
const viz = new PaletteViz({ palette });
document.querySelector('#app').appendChild(viz.canvas);
// or style it after the fact
viz.canvas.style.borderRadius = '50%';Multiple synchronised views
const palette = ['#264653', '#2a9d8f', '#e9c46a'];
const shared = { palette, width: 256, height: 256, container: document.querySelector('#views') };
const views = [
new PaletteViz({ ...shared, axis: 'x', colorModel: 'okhslPolar' }),
new PaletteViz({ ...shared, axis: 'y', colorModel: 'okhslPolar' }),
new PaletteViz({ ...shared, axis: 'z', colorModel: 'okhslPolar' }),
];
document.querySelector('#slider').addEventListener('input', (e) => {
views.forEach((v) => { v.position = +e.target.value; });
});Utility exports
import { paletteToRGBA, randomPalette, fragmentShader } from 'palette-shader';
// Get raw RGBA bytes (Uint8Array, sRGB, 4 bytes per color)
// Useful for building your own WebGL texture or processing palette data
const rgba = paletteToRGBA(['#ff0000', '#00ff00', '#0000ff']);
// Quick random palette for prototyping
const palette = randomPalette(16);
// Access the raw GLSL fragment shader string
console.log(fragmentShader);Dependencies
None. The library uses raw WebGL 2 and the browser's native CSS color parser. No runtime dependencies.
Browser support
Requires WebGL 2 (supported in all modern browsers and most mobile devices since ~2017). Use canvas.getContext('webgl2') availability to feature-detect if needed.
Development
git clone https://github.com/meodai/color-palette-shader.git
cd color-palette-shader
npm install
npm run dev # start demo dev server → http://localhost:5173
npm run build # build library → dist/
npm run typecheck # TypeScript type checkThe demo lives in demo/ and is a private workspace package. It resolves the library from src/ via a Vite alias so changes to the library are reflected immediately without a build step.
License
MIT © David Aerne