A palette picker that behaves like a paint-chip card: a fixed rack of sixteen sockets, of which you fill only as many as the job needs. Pick colours by dragging a triangle across a perceptual colour space and letting the tool distribute N points evenly inside it.
One self-contained HTML file. No build step, no dependencies, no network. Open it from disk, drop it on a static host, or embed it in an iframe.
Most palette tools hand you a ramp between two colours. A ramp is the wrong object. Interpolate red to blue and you pass through purple — a colour on no flag, in no brand guide, on no approved paint card. A palette is a set, not a gradient, and the difference shows the moment you try to fill anything with red, white and blue.
So ColorCard produces a set. card[i], never mix(a, b, t).
The rack metaphor comes from trades that already work this way without naming it:
- Automotive design — two-tone body and cabin, sometimes a third for stitch or calipers. The unused chips stay in the studio. The car does not display empty sockets.
- Paint manufacturers — the deck is enormous; the job gets a short named pull of two, four, twelve. The rest stays in the binder.
- Interior decorators — a scheme is a handful of chips taken from a room's light and its fabric. The sofa does not show the chips you left behind.
Hence: the rack is always 4×4. Sometimes only two wells have chips in them. Empty sockets never leave the page. The calling application receives a finished card and nothing else — not the empties, not the triangle, not the covering maths.
Lead. In stained glass the lead came is the metal between the panes — it borders every colour and is none of them. ColorCard carries it as a separate value, outside the palette array. If your application draws outlines, that is what the lead is for.
Clear. One socket may be empty-by-intent rather than unused: a hole. Regions
that draw it should render transparent, so artwork arrives pre-masked for
compositing. It travels as null in the array, so position is preserved.
const card = window.open(
"colorcard/index.html?theme=4th%20of%20July&n=6",
"colorcard",
"width=1200,height=800"
);
if (!card) alert("Allow pop-ups to open the ColorCard.");Query parameters, all optional:
| param | meaning |
|---|---|
theme |
name of a built-in or saved card to open with |
n |
how many sockets to start filled, 1–16 |
An iframe works identically — it posts to window.parent.
window.addEventListener("message", ev => {
// In production, check the origin you served ColorCard from:
// if (ev.origin !== "https://example.com") return;
const m = ev.data;
if (!m || m.type !== "palette-use" || !Array.isArray(m.rgb)) return;
const colours = m.rgb.map(c => c && `rgb(${c[0]},${c[1]},${c[2]})`);
// a null entry is the CLEAR chip — render it transparent, not as a colour
});Validate before you trust. ColorCard posts with targetOrigin: "*" so it
works from file://, where origins are null. If you serve it over HTTP,
check ev.origin and the message shape in your listener. Treat the payload as
untrusted input — it is a message from another window.
{
"type": "palette-use",
"name": "4th of July",
"n": 3,
"rgb": [[179, 25, 66], [255, 255, 255], [10, 49, 97]],
"lead": [17, 17, 17],
"background": [216, 207, 194]
}rgb— the filled sockets, in rack order, 1 to 16 entries.nullmeans the clear chip.lead— the outline colour. Not a member ofrgb.background— the context the chips were judged against. Useful if you are about to paint on it.n—rgb.length, for convenience.
If ColorCard is opened directly rather than by another page, Send downloads the same JSON instead of posting it. The file is a valid payload; parse it and carry on. Nothing is lost by using the tool standalone.
Everything happens in Oklab (Björn Ottosson, 2020), a perceptually uniform space in which equal numeric distance is roughly equal perceived difference. That property is what makes "distribute these evenly" mean anything: the same operation in sRGB or HSV clusters colours where the space is perceptually stretched.
Conversions are Ottosson's published matrices, verified against his reference values to 6×10⁻⁵ and round-tripping 4,096 sRGB colours with a worst channel error of 0.0003 / 255.
The canvas shows the a–b plane of Oklab at the mean lightness of the three corners. Colours outside the sRGB gamut render flat grey, so you can see where the triangle is reaching past what a screen can actually show.
A triangle on a chromaticity diagram is how colour science has always drawn a gamut — sRGB, Adobe RGB and Display P3 are each literally a triangle. That notation is borrowed here for selection: the triangle is the region you are willing to pick from.
Each corner carries its own lightness. A flat slice gives you three hues at one value — hue variation with no light and dark, which is not a palette. Three independent lightnesses tilt the triangle through the space, so the cover spans value as well as hue. Lightness is interpolated barycentrically across the triangle along with the a and b coordinates.
- N = 3 — the corners.
- N = 4 — the corners plus the centroid.
- N = 5…16 — corners pinned, the remaining sites relaxed toward an even spread by Lloyd's algorithm: sample the triangle densely in barycentric coordinates, assign each sample to its nearest site, move each free site to the centroid of the samples that chose it, repeat. Forty-five rounds is well past the point where the eye can see further movement.
Corners stay pinned so the palette keeps the extremes you chose. An unconstrained relaxation pulls everything toward the middle and quietly narrows your range.
The rack is fixed at sixteen and mostly empty. This is the whole idea, not a limitation. A generator that returns exactly N swatches gives you no sense of the deck the job was pulled from.
Lead is outside the array. Putting it in would make consumers guess which entry is the outline.
Clear is null, not a sentinel colour. A magic RGB triple would eventually
collide with a real colour someone wanted.
Only filled sockets are transmitted. The empties, the triangle geometry, the lightness sliders and the relaxation all stay on the page.
The colour input is the browser's. <input type="color"> opens Chromium's
or Firefox's own picker, not the operating system dialog — no web API exposes
that. What you get is a saturation square, a hue slider, a hex field and, in
Chromium, an eyedropper.
The parts are not new; the combination appears to be. Related work worth knowing:
- Adobe Color has the geometric half — drag a handle on a wheel and the others follow. But the shape is rigid, enforcing triadic, complementary, analogous. You steer it; you do not draw it.
- iWantHue has the algorithmic half — clustering a perceptual subspace for maximally distinct colours. The region is set by hue/chroma/lightness sliders rather than drawn, and it optimises for distinctness rather than even coverage.
- Oklab/OKLCH generators overwhelmingly produce tonal scales: fix hue and chroma, vary lightness. One dimension.
- Gamut triangles on CIE chromaticity diagrams are the notation this borrows.
I am not aware of a tool combining a drawn region, per-corner lightness, even coverage, and a partially-filled fixed rack. That is a statement about my search, not a claim of priority. If you know of one, please open an issue — a pointer to prior art is more useful than a novelty claim.
- sRGB only. The gamut mask and all output assume sRGB. No P3 or Rec.2020.
- No colour-blindness simulation or contrast checking. Even coverage in Oklab is not the same as distinguishable for everyone.
- The triangle is one region. No multi-region or exclusion zones.
- Saved cards live in
localStorage, so they are per-browser and per-origin, and are lost if site data is cleared. There is no export of the saved set. window.close()only works on a window that script opened. Opened by hand, the browser blocks it and the button says so instead.
MIT. See LICENSE.
Oklab is by Björn Ottosson, released into the public domain / under MIT at the author's option. The conversion matrices here are his.
Built for SpiroJoy, where the card fills the enclosed regions of a generative drawing and the lead draws the lines between them.
