Every designer and developer has been there: you have a brand color and need a palette that actually works — complementary, analogous, triadic, or something in between. Most tools either require an account or pull in a heavy design library. I built one that runs entirely in the browser with zero dependencies.
The result: Color Picker & Palette Generator — a free, client-side tool that converts colors between four formats and generates six types of palettes instantly.
👉 https://color-picker-62x.pages.dev
What It Does
-
Native color picker (
<input type="color">) — no custom UI needed, OS-native and accessible - Real-time conversion between HEX, RGB, HSL, and HSV
-
Click any value to copy — format strings like
hsl(243, 100%, 70%)go straight to your clipboard - Six palette types: Complementary, Analogous, Triadic, Tetradic, Split-Complementary, Monochromatic (6 shades)
- Click any swatch to copy its hex code
- Random color button for quick inspiration
- Zero external dependencies, works offline
The Color Math
The core is pure math — no canvas tricks, no DOM manipulation for conversion. Every format conversion is a direct function.
HEX ↔ RGB
Straightforward bit-shifting:
function hexToRgb(hex) {
const h = hex.replace('#', '');
const full = h.length === 3
? h.split('').map(c => c + c).join('')
: h;
const n = parseInt(full, 16);
return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff };
}
function rgbToHex({ r, g, b }) {
return '#' + [r, g, b]
.map(v => Math.round(v).toString(16).padStart(2, '0'))
.join('');
}
Short hex (#fff) is expanded by doubling each character before parsing.
RGB → HSL
The trickier conversion — HSL is the most useful space for palette generation because hue rotations map directly to color relationships:
function rgbToHsl({ r, g, b }) {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) {
const d = max - min;
s = d / (l > 0.5 ? 2 - max - min : max + min);
switch (max) {
case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
case gn: h = ((bn - rn) / d + 2) / 6; break;
case bn: h = ((rn - gn) / d + 4) / 6; break;
}
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
The saturation formula switches between two variants based on lightness — that's the subtle part. When l > 0.5, you're in the upper half of the HSL cylinder and the denominator changes.
RGB → HSV
HSV (also called HSB) is different from HSL in how it handles brightness. A fully saturated red at V=100 is rgb(255,0,0); at V=50 it's rgb(128,0,0). That makes HSV natural for adjusting brightness without washing out to white (which HSL's lightness does at L=100).
function rgbToHsv({ r, g, b }) {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
const d = max - min;
let h = 0;
const s = max === 0 ? 0 : d / max;
const v = max;
if (d !== 0) {
switch (max) {
case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
case gn: h = ((bn - rn) / d + 2) / 6; break;
case bn: h = ((rn - gn) / d + 4) / 6; break;
}
}
return { h: Math.round(h * 360), s: Math.round(s * 100), v: Math.round(v * 100) };
}
Palette Generation
All palettes derive from HSL hue rotation — that's why HSL is the right working space for this.
Hue normalization
Before any rotation, normalize hue to [0, 360):
function normalizeHue(h) { return ((h % 360) + 360) % 360; }
The double-mod pattern handles negative inputs correctly (e.g., normalizeHue(-60) → 300).
The six palette types
| Type | Colors | Logic |
|---|---|---|
| Complementary | 2 | hue + 180° |
| Analogous | 5 | hue ± 30°, ± 60° |
| Triadic | 3 | hue + 0°, 120°, 240° |
| Tetradic | 4 | hue + 0°, 90°, 180°, 270° |
| Split-Complementary | 3 | hue + 0°, 150°, 210° |
| Monochromatic | 6 | same hue/saturation, lightness 10–85% |
function paletteTriadic(hex) {
const { h, s, l } = hslFromHex(hex);
return [0, 120, 240].map(offset =>
hexFromHsl(normalizeHue(h + offset), s, l)
);
}
function paletteMonochromatic(hex) {
const { h, s } = hslFromHex(hex);
return [10, 25, 40, 55, 70, 85].map(l => hexFromHsl(h, s, l));
}
The monochromatic palette fixes hue and saturation, stepping lightness from dark to light. Six evenly-spaced steps from L=10 to L=85 gives a usable range across backgrounds and text.
Clipboard Copy
Two paths — modern async API with a document.execCommand fallback:
function copyToClipboard(text) {
try {
navigator.clipboard.writeText(text)
.then(() => showToast('Copied: ' + text));
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (_) {}
document.body.removeChild(ta);
showToast('Copied: ' + text);
}
}
The fallback matters because navigator.clipboard requires HTTPS and a focused document — both guaranteed in production, but not always in local dev.
Testing: 187 Cases, No Framework
The test suite covers every conversion function, round-trips, palette shapes, and edge cases — 25 sections, built with a 30-line inline runner.
| Section | Tests | What's covered |
|---|---|---|
| hexToRgb | 12 | 6-char, 3-char, known vectors |
| rgbToHex | 10 | known vectors, padding |
| rgbToHsl | 12 | primaries, achromatic, bounds |
| hslToRgb | 10 | primaries, achromatic, boundary hues |
| rgbToHsv | 10 | primaries, white, black, gray |
| normalizeHue | 10 | negative, >360, wrapping |
| HEX round-trip | 8 | hex → rgb → hex identity |
| RGB→HSL→RGB round-trip | 8 | Δ ≤ 1 per channel |
| paletteComplementary | 8 | count, hue offset, input preserved |
| paletteAnalogous | 8 | count, center matches input, hue steps |
| paletteTriadic | 8 | count, hue spacing 120° |
| paletteTetradic | 8 | count, hue spacing 90° |
| paletteSplitComplementary | 8 | count, offsets 150°/210° |
| paletteMonochromatic | 8 | count, same hue/s, lightness order |
| hslFromHex helper | 6 | known outputs |
| hexFromHsl helper | 6 | known outputs |
| rgbToHsl edge cases | 6 | achromatic, near-white/black |
| hslToRgb boundary | 6 | h=360, l=0/100, achromatic |
| Palette count checks | 6 | all 6 types |
| Total palette colors | 4 | 2+5+3+4+3+6 = 23 |
| Hue arithmetic | 6 | complement hues verified |
| rgbToHsv consistency | 6 | yellow, gray, cyan |
| HSL→RGB→HSV chain | 6 | hue preserved across chain |
| Palette hue spacing | 6 | triadic 120°, tetradic 90° |
| Format string construction | 6 | regex, range checks |
No Jest, no Mocha, no assert module — just:
function assert(condition, label) {
if (condition) { console.log(` ✓ ${label}`); passed++; }
else { console.error(` ✗ ${label}`); failed++; }
}
Run with npm test.
The Tricky Edge Case
The rgbToHsl saturation formula has a branch that trips people up:
s = d / (l > 0.5 ? 2 - max - min : max + min);
When lightness exceeds 0.5, the formula switches denominator. It took me three wrong implementations before I traced it back to the original HSL cylindrical model definition. The two-segment formula ensures saturation reaches 100% at both the brightest and darkest extremes.
Try It
https://color-picker-62x.pages.dev
Single index.html, no build step, no framework. Open DevTools and read the source.
Also part of devnestio — a collection of zero-dependency developer tools.
Built with: vanilla JS, the HSL spec, and a lot of hue rotation math.
Top comments (0)