palette-shader
v0.20.0
Published
Dependency-free WebGL2 shader that maps any colour palette across perceptual colour spaces — OKHsv, OKHsl, OKLCH and more.
Downloads
348
Maintainers
Readme
palette-shader
A dependency-free WebGL2 shader that maps any color palette across a 3-D perceptual color space and snaps each pixel to the nearest palette color. Visualize how a palette distributes across 30+ color models and eleven distance metrics, all on the GPU. Includes 2-D cross-section views (PaletteViz) and an interactive 3-D cube/cylinder view (PaletteViz3D) with trackball rotation.
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
Common questions → recommended settings
| I want to know… | Color model | Distance metric |
| ----------------------------------------- | ------------------------------- | --------------- |
| How my hue distribution looks | okhslPolar | oklab |
| Which two colors are most similar | okhsl or oklab | deltaE2000 |
| Whether a new color is worth adding | okhslPolar | oklab |
| How my palette reads on print | cielabD50 or cielchD50Polar | cielabD50 |
| How close the colors look to a human eye | oklchPolar | deltaE2000 |
| What the palette looks like to a computer | hslPolar or rgb | rgb |
For a deeper breakdown of every color model and metric, see docs/use-cases.md.
Install
npm install palette-shaderNo runtime dependencies — only a browser with WebGL2 support is required. Colors must be passed as [r, g, b] arrays with values in the 0–1 range (linear sRGB). Use a library such as culori to convert from CSS strings if needed.
Quick start
import { PaletteViz } from 'palette-shader';
import { converter } from 'culori';
const toSRGB = converter('srgb');
const toRGB = (hex) => {
const c = toSRGB(hex);
return [c.r, c.g, c.b];
};
// option A — pass a container, canvas is appended automatically
const viz = new PaletteViz({
palette: ['#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'].map(toRGB),
container: document.querySelector('#app'),
width: 512,
height: 512,
observeResize: false,
});
// option B — no container, place the canvas yourself
const viz = new PaletteViz({ palette: ['#264653', '#2a9d8f', '#e9c46a'].map(toRGB) });
document.querySelector('#app').appendChild(viz.canvas);If you only use the 2D renderer, import PaletteViz and let your bundler tree-shake the rest. PaletteViz3D pulls in substantially more code for mesh generation, extra shaders, and interaction handling, so it is worth avoiding in apps that never render the 3-D view.
Constructor
new PaletteViz(options?: PaletteVizOptions)All options are optional. The palette defaults to a random 20-color set.
| Option | Type | Default | Description |
| ---------------- | ---------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| palette | [number, number, number][] | random | sRGB colors as [r, g, b] arrays, each component in the 0–1 range |
| 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 |
| observeResize | boolean | false | When true, a ResizeObserver tracks the laid-out canvas size and updates the backing resolution to match CSS layout. When false, width and height are treated as the explicit display size. |
| colorModel | string | 'okhsv' | Color space for the visualization (see Color models) |
| distanceMetric | string | 'oklab' | Distance function for nearest-color 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 |
| invertAxes | ('x' \| 'y' \| 'z')[] | [] | Invert one or more axes, for example ['z'] or ['x', 'z']. In 2-D polar views, y inversion is resolved as a vertical view flip to avoid the mirrored center seam. |
| showRaw | boolean | false | Bypass nearest-color matching (shows the raw color space) |
| outlineWidth | number | 0 | Draw a transparent outline where palette regions meet. Width in physical pixels. 0 disables (no overhead). |
| gamutClip | boolean | false | Discard out-of-sRGB-gamut pixels instead of clamping. Reveals the true gamut boundary of the color model. |
Properties
Every constructor option is also a live setter/getter. Assigning any of them re-renders immediately via requestAnimationFrame.
viz.palette = [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
];
viz.position = 0.5;
viz.colorModel = 'okhslPolar';
viz.distanceMetric = 'deltaE2000';
viz.invertAxes = ['z'];
viz.showRaw = true;
viz.outlineWidth = 2; // transparent border between regions, in physical pixels
viz.gamutClip = true; // discard out-of-gamut pixels
viz.pixelRatio = window.devicePixelRatio; // update after display changesSizing model:
- By default,
widthandheightare the explicit display size and backing resolution source. - If you want normal CSS layout to control the canvas size, set
observeResize: trueand style the canvas or its container in CSS. pixelRatioonly affects backing resolution, not layout size.
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([0.902, 0.224, 0.275], 2);addColor(color, index?)
Insert a color at index (appends if omitted).
viz.addColor([0.659, 0.855, 0.863]); // append
viz.addColor([0.271, 0.482, 0.616], 0); // prependremoveColor(index | color)
Remove a palette entry by index or by color value.
viz.removeColor(0);
viz.removeColor([0.659, 0.855, 0.863]);destroy()
Cancel the animation frame, release all WebGL resources (programs, textures, framebuffer, buffer, VAO), and remove the canvas from the DOM.
render()
Force a synchronous render immediately, bypassing the requestAnimationFrame schedule. Use this when the canvas (or its WebGL drawing buffer) must reflect the latest state right now — for example before reading pixels back from the canvas in the same JS tick.
viz.render();
// canvas drawing buffer is now up to date — safe to readPixels(...)getColorAtUV(x, y)
Returns the current shader result at normalized UV coordinates (0–1 on both axes) as [r, g, b] in 0–1 sRGB. This reads directly from the WebGL render target (or outline source buffer), not from DOM canvas sampling.
When a paint is already pending (state changed since the last frame) it renders first; when the render target is already current it skips the redundant render and reads back directly. If you need to guarantee a fresh render as a side effect, call render() explicitly.
const color = viz.getColorAtUV(0.5, 0.5); // centergetColorAtUV_float(x, y)
Returns the color at normalized UV coordinates as [r, g, b] in unclamped linear RGB with full float precision. Unlike getColorAtUV, values are not quantized to 8-bit and are not clamped to [0, 1] — out-of-gamut colors preserve their original value. The sRGB transfer function is bypassed so the consumer receives linear-light values suitable for further color-space conversions without double-gamma.
Renders a single pixel on demand into a 1×1 RGBA16F framebuffer — zero per-frame cost.
const [r, g, b] = viz.getColorAtUV_float(0.5, 0.5);
// r, g, b are linear RGB — may be < 0 or > 1 for out-of-gamut colorsConverting to OKLab (or any other color space) with culori:
import { converter, formatCss } from 'culori';
const toOklab = converter('oklab');
const [r, g, b] = viz.getColorAtUV_float(0.5, 0.5);
// Feed linear RGB directly into culori's linear-sRGB mode
const oklab = toOklab({ mode: 'lrgb', r, g, b });
// → { mode: 'oklab', l: 0.72, a: 0.08, b: 0.12 }
formatCss(oklab);
// → 'oklab(0.72 0.08 0.12)'Color models
Controls the 3-D color space the visualization is rendered in. Polar variants (*Polar) map hue to angle and show a circular wheel; non-polar variants show a rectangular slice.
OK — hue-based
| Value | Shape | Description |
| -------------- | ----- | --------------------------------------------------------------------------------------------------------- |
| 'okhsv' | cube | Default. Hue–Saturation–Value built on OKLab. Gamut-aware with perceptually uniform saturation steps. |
| 'okhsvPolar' | wheel | Polar form of OKHsv. |
| 'okhsl' | cube | Hue–Saturation–Lightness built on OKLab. Better lightness uniformity across hues. |
| 'okhslPolar' | wheel | Polar form of OKHsl. |
OK — Lab / LCH
| Value | Shape | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 'oklab' | cube | Raw OKLab: x→a, y→b, z→L. |
| 'oklch' | cube | OKLab in cylindrical LCH coordinates. Ideal for chroma or lightness slices. |
| 'oklchPolar' | wheel | Polar form of OKLch. |
| 'oklchDiag' | diagonal | Complementary hue plane: lightness on the diagonal, signed chroma on the anti-diagonal. Two opposite hues share the same view — no gray band in the middle. |
| 'oklrab' | cube | OKLab with toe-corrected lightness (Lr). Better perceptual uniformity in dark tones. |
| 'oklrch' | cube | OKLrab in cylindrical LCH coordinates. |
| 'oklrchPolar' | wheel | Polar form of OKLrch. |
| 'oklrchDiag' | diagonal | Complementary hue plane in OKLrch. Same diagonal layout as oklchDiag with toe-corrected lightness. |
CIE Lab / LCH — D65
| Value | Shape | Description |
| --------------- | ----- | -------------------------------------------------------------- |
| 'cielab' | cube | CIELab D65: x→a, y→b, z→L. The classic perceptual color space. |
| 'cielch' | cube | CIELab D65 in cylindrical LCH coordinates. |
| 'cielchPolar' | wheel | Polar form of CIELch D65. |
CIE Lab / LCH — D50
| Value | Shape | Description |
| ------------------ | ----- | -------------------------------------------------------- |
| 'cielabD50' | cube | CIELab adapted to D50 illuminant (ICC / print standard). |
| 'cielchD50' | cube | CIELab D50 in cylindrical LCH coordinates. |
| 'cielchD50Polar' | wheel | Polar form of CIELch D50. |
CAM16-UCS — D65
| Value | Shape | Description |
| -------------------- | ----- | -------------------------------------------------------------------------------------- |
| 'cam16ucsD65' | cube | CAM16-UCS under fixed D65 CAT16 viewing conditions, rendered with an analytic inverse. |
| 'cam16ucsD65Polar' | wheel | Polar CAM16-UCS view under the same fixed D65 conditions. |
Classic
| Value | Shape | Description |
| ------------ | ----- | ---------------------------------------------------- |
| 'hsv' | cube | Classic HSV. Not perceptually uniform, but familiar. |
| 'hsvPolar' | wheel | Polar form of HSV. |
| 'hsl' | cube | Classic HSL. |
| 'hslPolar' | wheel | Polar form of HSL. |
| 'hwb' | cube | HWB (Hue–Whiteness–Blackness). CSS Color 4 model. |
| 'hwbPolar' | wheel | Polar form of HWB. |
| 'rgb' | cube | Raw sRGB cube. Useful as a baseline. |
Quantized RGB
| Value | Shape | Description |
| ------------ | ----- | ----------------------------------------------------- |
| 'rgb6bit' | cube | 2-bit per channel (64 colors). Game Boy–era palettes. |
| 'rgb8bit' | cube | 3-3-2 bit (256 colors). CGA-style quantization. |
| 'rgb12bit' | cube | 4-bit per channel (4096 colors). Amiga / NTSC. |
| 'rgb15bit' | cube | 5-bit per channel (32768 colors). SVGA HiColor. |
| 'rgb18bit' | cube | 6-bit per channel (262144 colors). VGA. |
Spectral
| Value | Shape | Description |
| ------------ | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 'spectrum' | cube | Visible light wavelengths (410–665 nm) plus purple line, modulated in OKLab. Inspired by censor's SpectroBoxWidget. X→wavelength, Y→lightness, Z→chroma scale. |
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 color 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 color 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 center, and the third axis is controlled by position. This matches the intuition most designers have for color — it's immediately obvious whether two colors 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 color" is determined per pixel.
OK / appearance-inspired
| Value | Description | Cost |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---- |
| 'oklab' | Default. Euclidean distance in OKLab. Fast, perceptually uniform, excellent general-purpose choice. | low |
| 'oklrab' | Euclidean in OKLab with toe-corrected lightness. Slightly better uniformity in dark tones than OKLab. | low |
| 'okLightness' | Absolute lightness difference in OKLab (ΔL). Ignores hue and chroma, so regions are grouped by brightness only. | low |
| 'liMatch' | Spatially varying blend: full OKLab distance at the left edge, pure lightness match at the right. Inspired by censor's li-match. | low |
| 'cam16ucsD65' | Euclidean distance in CAM16-UCS under fixed D65 CAT16 viewing conditions. Useful when you want a full appearance-model metric like censor. | high |
CIE — D65
| Value | Description | Cost |
| -------------- | ---------------------------------------------------------------------------------------------------------- | ------ |
| 'deltaE76' | Euclidean distance in CIELab D65. Identical to ΔE76. Classic standard, decent uniformity. | medium |
| 'deltaE94' | CIE 1994: adds chroma and hue weighting. Better than ΔE76, cheaper than ΔE2000. | medium |
| 'deltaE2000' | CIEDE2000: per-channel corrections for hue, chroma and lightness. Most accurate CIE formula, most complex. | high |
CIE — D50
| Value | Description | Cost |
| ------------- | ------------------------------------------------------------------------------- | ------ |
| 'cielabD50' | Euclidean distance in CIELab D50. Useful when working in print / ICC workflows. | medium |
Heuristic / simple
| Value | Description | Cost |
| ------------------- | -------------------------------------------------------------------------------------------------------- | ------ |
| 'kotsarenkoRamos' | Weighted Euclidean in sRGB. Weights R and B by mean red for quick perceptual improvement over plain RGB. | lowest |
| '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'].map(toRGB);
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;
});
});Transparent outlines between regions
outlineWidth draws a transparent gap where one palette color's region meets another, revealing whatever is behind the canvas. Width is in physical pixels (i.e. it already accounts for pixelRatio).
const viz = new PaletteViz({
palette: ['#264653', '#2a9d8f', '#e9c46a'].map(toRGB),
outlineWidth: 2,
container: document.querySelector('#app'),
});
// change at runtime — no shader recompile while the value stays > 0
viz.outlineWidth = 4;
// set back to 0 to disable entirely (zero GPU overhead)
viz.outlineWidth = 0;Implemented as a two-pass render: pass 1 draws the color regions into an offscreen framebuffer at the same cost as without outlines; pass 2 runs a tiny edge-detection shader that checks four neighbors via texture reads (no color-space math). The result is that enabling outlines adds negligible overhead compared to the single-pass approach.
When outlineWidth is 0 (the default) the framebuffer and outline program are never allocated.
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([
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
]);
// Quick random palette for prototyping
const palette = randomPalette(16);
// Access the raw GLSL fragment shader string
console.log(fragmentShader);PaletteViz3D
Renders the full 3-D color space as an interactive cube (or cylinder for polar models) that you can rotate with trackball-style controls. Each surface voxel runs the same modelToRGB pipeline as PaletteViz and is snapped to the nearest palette color.
Quick start (3D)
import { PaletteViz3D } from 'palette-shader';
const viz3d = new PaletteViz3D({
palette: ['#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'].map(toRGB),
container: document.querySelector('#app'),
colorModel: 'okhsv',
position: 1.0, // 1 = full volume, 0 = fully sliced
outlineWidth: 2,
});Constructor (3D)
new PaletteViz3D(options?: PaletteViz3DOptions)| Option | Type | Default | Description |
| ---------------- | ---------------------------- | --------------------- | ---------------------------------------------------------------------------- |
| palette | [number, number, number][] | random | sRGB colors as [r, g, b], each in 0–1 |
| container | HTMLElement | undefined | Element the canvas is appended to |
| 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' | Color model (see Color models). Polar → cylinder mesh |
| distanceMetric | string | 'oklab' | Distance metric (see Distance metrics) |
| position | number | 1 | 0–1 slice position. 1 shows the full volume; 0 slices it completely away |
| invertAxes | ('x' \| 'y' \| 'z')[] | [] | Invert one or more axes, for example ['z'] or ['x', 'z'] |
| showRaw | boolean | false | Bypass nearest-color matching |
| outlineWidth | number | 0 | Transparent outline width (physical px). 0 disables |
| gamutClip | boolean | false | Discard out-of-sRGB-gamut pixels instead of clamping |
| modelMatrix | Float32Array | slight tilt (default) | Initial 4×4 column-major model rotation matrix |
Properties (3D)
All constructor options except modelMatrix are live setter/getters (re-render on assignment), identical to PaletteViz.
Additional properties:
| Property | Type | Description |
| ------------- | ------------------- | -------------------------------------------------------------- |
| canvas | HTMLCanvasElement | The canvas (read-only) |
| modelMatrix | Float32Array | Get/set the 4×4 model rotation matrix (copies on read & write) |
Methods (3D)
getColorAtUV(x, y)
Returns the rendered color at normalised screen coordinates (0–1 on both axes, y=0 is top) as [r, g, b] in 0–1 sRGB, or null if the cursor is over a transparent pixel (i.e. outside the 3D geometry). Flushes any pending rAF frame so the reading is always current.
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const color = viz3d.getColorAtUV(
(e.clientX - rect.left) / rect.width,
(e.clientY - rect.top) / rect.height,
);
if (color) {
const hex =
'#' +
color
.map((c) =>
Math.round(c * 255)
.toString(16)
.padStart(2, '0'),
)
.join('');
console.log(hex);
}
});rotate(dx, dy)
Apply an incremental trackball rotation. dx and dy are in radians (screen-space). Left-multiplies incremental X/Y rotations onto the accumulated model matrix.
// wire up pointer events
canvas.addEventListener('pointermove', (e) => {
if (e.buttons) viz3d.rotate(e.movementX * 0.01, e.movementY * 0.01);
});resize(width, height?)
Same as PaletteViz.
destroy()
Release all WebGL resources and remove the canvas.
Matrix helpers
The library exports lightweight 4×4 column-major matrix functions so you can build custom orbit / trackball controls without a math library:
import {
mat4Perspective,
mat4Multiply,
mat4RotateX,
mat4RotateY,
mat4Translate,
} from 'palette-shader';
// compose a custom model matrix and apply it
const model = mat4Multiply(mat4RotateX(0.4), mat4RotateY(0.6));
viz3d.modelMatrix = model;| Function | Signature | Description |
| ----------------- | ----------------------------------------- | -------------------------------- |
| mat4Perspective | (fov, aspect, near, far) → Float32Array | Perspective projection matrix |
| mat4Multiply | (a, b) → Float32Array | Matrix multiplication a × b |
| mat4RotateX | (angle) → Float32Array | Rotation around X axis (radians) |
| mat4RotateY | (angle) → Float32Array | Rotation around Y axis (radians) |
| mat4Translate | (x, y, z) → Float32Array | Translation matrix |
Dependencies
None. The library uses raw WebGL 2 with no runtime dependencies. Colors are accepted as [r, g, b] arrays (0–1 sRGB) — no CSS parsing happens at runtime.
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
