Why This Project?
Color is math. Every design tool you've ever used — Figma, Coolors, Adobe Color — computes its palettes using the same trigonometry on the HSL color wheel. This chapter strips away the library and builds that math directly in vanilla JavaScript.
You'll learn to convert between hex and HSL, derive harmonic colors by rotating the hue angle, generate tint/shade scales via lightness interpolation, and export the result as CSS custom properties, SCSS variables, JSON, or a Tailwind config — all from the same underlying data array.
HSL Color Model
Hue (0–360°) is the color wheel position. Saturation (0–100%) is intensity. Lightness (0–100%) is brightness. Tints increase L; shades decrease L. Harmonics rotate H.
Hex ↔ HSL Conversion
Convert hex → RGB (divide by 255) → normalize to find min/max channel → compute H, S, L. Reverse: use the cubic formula from the CSS spec. No library needed — it's 15 lines.
Color Harmonics
Complementary = H + 180°. Triadic = H, H+120°, H+240°. Analogous = H±30°. Split-complementary = H+150°, H+210°. All are just modular arithmetic on the hue circle.
Dynamic Code Generation
The same palette object emits CSS, SCSS, JSON, or Tailwind config — just different string templates over the same key/value pairs. This is the factory pattern: one data model, many output formats.
The AI Angle for This Chapter
Color math is a precision task. The hex-to-HSL conversion must be exact — a small rounding error compounds through every derived color. This is a case where asking AI to "write a color converter" will give you working code, but you need to understand the algorithm well enough to verify it against known values.
Verify with known values before trusting generated math. Prompt: "Convert #e00000 to HSL. Show the calculation step by step." The answer should be hsl(0°, 100%, 44%). If AI gives you something different, its formula is wrong. Give it the expected output and ask it to fix the derivation — don't just accept the first result on color math.
The most powerful prompt technique for this chapter is describing the output format first. Write the exact CSS variables you want to see, then ask AI to write the function that generates them. The spec becomes your test case.
Before You Build — Think in HSL
Before writing any code, sketch the palette on paper using HSL values:
- Take any brand hex — say
#e00000(Internationally Tribal red). What is its H, S, L? Compute by hand or use browser DevTools color picker. - What hex color is the complement? It's H+180°. If H=0, complement H=180 (cyan). Does that feel right visually?
- How would you generate 5 tints? You want to move L from the base value toward 95% in equal steps. Write the formula:
L_tint_n = base_L + (95 - base_L) * (n / steps). - If you want shades (darker), you move L toward 8%. Write that formula too. Notice it's the same structure with a different target.
- For neutral colors, you keep the same H but drop S to ~8% and vary L from dark to light. Why keep H at all instead of using pure gray (S=0)?
The math is simple linear interpolation and modular arithmetic. Everything else in this chapter is presentation around those two operations.
Build It
Build the hex ↔ HSL conversion utilities
These are the foundation. Every other function depends on them. Build hexToHsl(hex) and hslToHex(h,s,l) and test them manually before moving on.
Build hexToHsl(hex) that:
1. Parses the 6-digit hex into r, g, b (0–255 each)
2. Normalizes to 0–1 range
3. Finds max and min channel values
4. Computes L = (max+min)/2
5. Computes S = d / (1 - |2L-1|) where d = max-min
6. Computes H based on which channel is max
7. Returns [H (0-360), S (0-100), L (0-100)] as integers
Test: hexToHsl('#e00000') → [0, 100, 44]
Test: hexToHsl('#ffffff') → [0, 0, 100]
Test: hexToHsl('#0057b7') → [213, 100, 36]
Build scale and harmony generators
With conversion working, write getScale(hex, steps, satShift) that returns an array of color objects with hex and role, and getHarmony(hex, mode) that returns harmonic colors based on hue rotation.
getScale(hex, steps, satShift):
- Tints: for i from (steps-1) down to 1:
L_tint = base_L + (95 - base_L) * (i / steps)
Push { hex: hslToHex(H, S, L_tint), role: 'tint-i' }
- Push base color: { hex, role: 'base' }
- Shades: for i from 1 to steps-1:
L_shade = base_L - (base_L - 8) * (i / steps)
Push { hex: hslToHex(H, S, L_shade), role: 'shade-i' }
- satShift adjusts S on derived colors (clamp 0-100)
getHarmony(hex, mode): use a lookup object keyed by mode name.
Each value is an array of { hex: hslToHex(H+offset, S, L), role }.
Complementary offset: 180. Triadic: 0, 120, 240. Analogous: -30, 0, 30.
Build the swatch renderer
Render each color as a clickable swatch that shows hex + role. Clicking any swatch copies its hex to the clipboard using navigator.clipboard.writeText() and shows a brief "Copied!" overlay.
makeSwatch(color, showName, darkLabel):
1. Determine label text color: if L > 55 use dark (#111), else white
2. Create a div with background: color.hex
3. Inner label: hex value (bold) + role name (small)
4. On click: navigator.clipboard.writeText(color.hex),
add class 'copied' for 1s (CSS ::after overlay: "Copied!")
For harmony colors use a card layout:
- Top half: color fill
- Bottom: role label, hex, hsl() string, optional name
Build multi-format code output
All colors from all three rows (scale, harmony, neutrals) should map to named variables. One buildCode(scale, harmony, neutrals, format, name) function handles CSS, SCSS, JSON, and Tailwind.
Build a flat array of [variableName, hexValue] pairs.
For CSS:
':root {\n' + pairs.map(([k,v]) => ` --${k}: ${v};`).join('\n') + '\n}'
For SCSS:
pairs.map(([k,v]) => `$${k}: ${v};`).join('\n')
For JSON:
JSON.stringify(Object.fromEntries(pairs), null, 2)
For Tailwind:
Group pairs by prefix, nest under theme.extend.colors,
output as module.exports JS string
Build SVG swatch sheet export
Generate a downloadable SVG showing all swatches as a labeled grid. Use the same Blob + Object URL download pattern from Chapter 05 — this time with type: 'image/svg+xml'.
downloadSVG():
1. Combine all colors into one flat array
2. Calculate grid dimensions: cols = min(length, 10), rows = ceil(length/cols)
3. For each color, emit:
<rect x y width height rx fill={hex}/>
<text>hex value</text>
<text>role name</text>
4. Add palette title text at top
5. Wrap in <svg> with dark background fill
6. Blob with type 'image/svg+xml', download as 'brand-swatches.svg'
Concepts You Just Used
- HSL color model — expressing colors as position on a wheel (H), intensity (S), and brightness (L) makes palette math intuitive and predictable
- Linear interpolation —
start + (end - start) * twhere t is 0–1; used for every tint and shade step - Modular arithmetic —
(H + 180) % 360wraps the hue angle around the 360° wheel - CSS custom properties —
--variable-name: valuedeclared in:rootare the modern standard for design tokens - Data → format factory — one array of
[key, value]pairs, multiple output string templates; the data is format-agnostic - SVG generation — SVG is just XML; any string of well-formed SVG tags is a valid, downloadable vector file
- navigator.clipboard.writeText() — async copy to clipboard; always handle the
.catch()for permission denied - Live reactivity — attaching
inputandchangelisteners to all controls and calling one regenerate function creates instant feedback
CSS custom properties are design tokens. The palette you export in this chapter is the same artifact that feeds Figma token plugins, Storybook theming, and component libraries. You just built the primitive layer of a full design system — without Figma, without Style Dictionary, without npm.
Reflect
- Why is HSL more useful than RGB for generating palettes? What would tint generation look like if you had to do it in RGB instead?
- The complementary of red (H=0°) is cyan (H=180°). Why does that combination feel so intense? What would you do to make it feel more harmonious in a real brand?
- This tool generates colors mathematically. A human designer would adjust them by feel. What's one thing a human would do that this tool can't — and how would you add a control for it?
- The neutral scale keeps the base hue at low saturation. How does that compare to using pure gray (
s=0)? Which approach is better for brand consistency? - The SVG export is a flat grid. How would you change the layout to a horizontal strip (one row per color group) with larger swatches?
You didn't install a color library. You wrote the math that is the color library. That's the difference between using tools and understanding them.
Go Further
🎨 Contrast Checker
Add WCAG contrast ratio calculation between any two selected swatches. Show pass/fail for AA (4.5:1) and AAA (7:1) standards. The formula uses relative luminance from the W3C spec.
🖼 Image Color Extraction
Use a <canvas> element to sample pixels from a dropped image. Average the top 5 most prominent hues and use one as the base color input automatically.
🌐 Palette History
Save up to 10 recent palettes in localStorage keyed by base hex + mode. Add a dropdown to reload any saved palette. Include a "pin" option to keep favorites.
📇 Figma Token Export
Figma Tokens plugin uses a specific JSON structure. Build a fifth export format that generates a tokens.json compatible with the plugin's import schema.
👀 Color Blindness Preview
Simulate deuteranopia and protanopia by applying the known color matrix transform to your palette swatches in a side-by-side preview panel.
🍀 Gradient Builder
Take any two swatches from your palette and generate a CSS linear-gradient between them with 5 intermediate stops. Preview it as a full-width banner and copy the CSS.
Markdown Live Editor
Build a split-pane Markdown editor with live preview, custom toolbar, and export to HTML or PDF. You'll learn DOM parsing, regex-based text transforms, and print-to-PDF from the browser.
Start Chapter 07 →