DEV Community

Dev Nestio
Dev Nestio

Posted on

I Built a Color Picker & Palette Generator in Pure Vanilla JS — No Libraries, 187 Tests

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('');
}
Enter fullscreen mode Exit fullscreen mode

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) };
}
Enter fullscreen mode Exit fullscreen mode

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) };
}
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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++; }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)