Home Module 15 Color Generator

Project Overview

Colours are everywhere in frontend work. This project practises bitwise arithmetic (HEX conversion), HSL colour math, clipboard API, and dynamic DOM — all in one visually satisfying tool.

What you will build

  • Random colour generator — click to generate a new colour
  • Large colour preview panel
  • Display HEX, RGB, and HSL values simultaneously
  • Copy any format to clipboard with one click
  • Palette generator — produce 5 complementary shades
  • Saved colours shelf (up to 12, persisted in localStorage)

Concepts used

  • Bitwise operations (>> and &) for colour channel extraction
  • HSL to RGB conversion algorithm
  • Template literals for colour strings
  • navigator.clipboard.writeText()
  • Dynamic background colour updates

Key Logic

Random HEX generation

function randomHex() {
  // Generate a random integer 0–16777215 (0xFFFFFF)
  const n = Math.floor(Math.random() * 0xFFFFFF + 1);
  // toString(16) converts to hex; padStart ensures 6 digits
  return '#' + n.toString(16).padStart(6, '0').toUpperCase();
}

HEX to RGB

function hexToRgb(hex) {
  const n = parseInt(hex.slice(1), 16); // remove # and parse
  return {
    r: (n >> 16) & 0xFF,   // top 8 bits
    g: (n >>  8) & 0xFF,   // middle 8 bits
    b:  n        & 0xFF    // bottom 8 bits
  };
}

RGB to HSL

function rgbToHsl(r, g, b) {
  r /= 255; g /= 255; b /= 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  const l = (max + min) / 2;
  let h = 0, s = 0;

  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
      case g: h = ((b - r) / d + 2) / 6; break;
      case b: h = ((r - g) / d + 4) / 6; break;
    }
  }
  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100)
  };
}

Palette: shades of the same hue

function buildPalette(hex) {
  const { r, g, b } = hexToRgb(hex);
  const { h, s }    = rgbToHsl(r, g, b);
  // Five evenly spaced lightness values
  return [20, 35, 50, 65, 80].map(l => hslToHex(h, s, l));
}

Complete Working Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Color Generator</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0;
           min-height: 100vh; display: flex; align-items: center;
           justify-content: center; padding: 1rem; }
    .wrap { width: 100%; max-width: 480px; }
    .preview {
      height: 200px; border-radius: 1rem; margin-bottom: 1.25rem;
      transition: background .3s; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      font-size: .9rem; color: #fff8; user-select: none;
      box-shadow: 0 8px 32px #0004;
    }
    .codes {
      display: grid; grid-template-columns: repeat(3, 1fr);
      gap: .75rem; margin-bottom: 1.25rem;
    }
    .code-box {
      background: #1e293b; border-radius: .6rem; padding: .75rem;
      text-align: center; cursor: pointer; transition: background .15s;
      border: 2px solid transparent;
    }
    .code-box:hover { background: #334155; border-color: #3b82f6; }
    .code-label { font-size: .7rem; color: #64748b; margin-bottom: .3rem; text-transform: uppercase; letter-spacing: .05em; }
    .code-val { font-family: monospace; font-size: .9rem; color: #38bdf8; }
    .row { display: flex; gap: .75rem; margin-bottom: 1.25rem; }
    .btn {
      flex: 1; padding: .8rem; background: #3b82f6; color: #fff;
      border: none; border-radius: .6rem; font-size: .95rem;
      font-weight: 600; cursor: pointer;
    }
    .btn:hover { background: #2563eb; }
    .btn.secondary { background: #1e293b; color: #94a3b8; }
    .btn.secondary:hover { background: #334155; }
    h3 { font-size: .85rem; color: #64748b; text-transform: uppercase;
         letter-spacing: .05em; margin-bottom: .6rem; }
    .palette { display: flex; gap: .5rem; margin-bottom: 1.25rem; border-radius: .6rem; overflow: hidden; }
    .swatch {
      flex: 1; height: 50px; cursor: pointer; transition: transform .15s;
      position: relative;
    }
    .swatch:hover { transform: scaleY(1.1); z-index: 1; }
    .saved-shelf { display: flex; flex-wrap: wrap; gap: .5rem; }
    .saved-swatch {
      width: 40px; height: 40px; border-radius: .4rem;
      cursor: pointer; border: 2px solid transparent;
      transition: transform .15s, border-color .15s;
    }
    .saved-swatch:hover { transform: scale(1.15); border-color: #fff4; }
    .toast {
      position: fixed; bottom: 2rem; left: 50%;
      transform: translateX(-50%) translateY(100px);
      background: #22c55e; color: #fff;
      padding: .5rem 1.5rem; border-radius: 999px;
      font-size: .85rem; transition: transform .3s; pointer-events: none;
    }
    .toast.show { transform: translateX(-50%) translateY(0); }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="preview" id="preview">Click to generate</div>
    <div class="codes">
      <div class="code-box" id="hex-box">
        <div class="code-label">HEX</div>
        <div class="code-val" id="hex-val">—</div>
      </div>
      <div class="code-box" id="rgb-box">
        <div class="code-label">RGB</div>
        <div class="code-val" id="rgb-val">—</div>
      </div>
      <div class="code-box" id="hsl-box">
        <div class="code-label">HSL</div>
        <div class="code-val" id="hsl-val">—</div>
      </div>
    </div>
    <div class="row">
      <button class="btn" id="gen-btn">Generate</button>
      <button class="btn secondary" id="save-btn">Save Colour</button>
    </div>
    <h3>Palette</h3>
    <div class="palette" id="palette"></div>
    <h3>Saved</h3>
    <div class="saved-shelf" id="saved-shelf"></div>
  </div>
  <div class="toast" id="toast"></div>

  <script>
    const STORAGE_KEY = 'saved_colors';
    let current = '';

    const preview    = document.querySelector('#preview');
    const hexVal     = document.querySelector('#hex-val');
    const rgbVal     = document.querySelector('#rgb-val');
    const hslVal     = document.querySelector('#hsl-val');
    const palette    = document.querySelector('#palette');
    const savedShelf = document.querySelector('#saved-shelf');
    const toast      = document.querySelector('#toast');

    // ── Colour math ──────────────────────────────────────────
    function randomHex() {
      return '#' + Math.floor(Math.random() * 0xFFFFFF + 1)
                       .toString(16).padStart(6, '0').toUpperCase();
    }

    function hexToRgb(hex) {
      const n = parseInt(hex.slice(1), 16);
      return { r: (n >> 16) & 0xFF, g: (n >> 8) & 0xFF, b: n & 0xFF };
    }

    function rgbToHsl(r, g, b) {
      r /= 255; g /= 255; b /= 255;
      const max = Math.max(r, g, b), min = Math.min(r, g, b);
      const l = (max + min) / 2;
      let h = 0, s = 0;
      if (max !== min) {
        const d = max - min;
        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
        switch (max) {
          case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
          case g: h = ((b - r) / d + 2) / 6; break;
          case b: h = ((r - g) / d + 4) / 6; break;
        }
      }
      return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
    }

    function hslToHex(h, s, l) {
      s /= 100; l /= 100;
      const k = n => (n + h / 30) % 12;
      const a = s * Math.min(l, 1 - l);
      const f = n => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))));
      return '#' + [f(0), f(8), f(4)].map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase();
    }

    // ── UI ───────────────────────────────────────────────────
    function showToast(msg) {
      toast.textContent = msg;
      toast.classList.add('show');
      setTimeout(() => toast.classList.remove('show'), 1800);
    }

    async function copyText(text) {
      try {
        await navigator.clipboard.writeText(text);
        showToast(`Copied: ${text}`);
      } catch {
        showToast('Copy failed — try manually');
      }
    }

    function setColor(hex) {
      current = hex;
      const { r, g, b } = hexToRgb(hex);
      const { h, s, l } = rgbToHsl(r, g, b);
      const rgb = `rgb(${r}, ${g}, ${b})`;
      const hsl = `hsl(${h}, ${s}%, ${l}%)`;

      preview.style.background = hex;
      preview.textContent = '';

      hexVal.textContent = hex;
      rgbVal.textContent = rgb;
      hslVal.textContent = hsl;

      // Palette
      palette.innerHTML = '';
      [20, 35, 50, 65, 80].forEach(shade => {
        const sw = document.createElement('div');
        sw.className = 'swatch';
        const shadeHex = hslToHex(h, s, shade);
        sw.style.background = shadeHex;
        sw.title = shadeHex;
        sw.dataset.hex = shadeHex;
        palette.appendChild(sw);
      });
    }

    function renderSaved() {
      const saved = getSaved();
      savedShelf.innerHTML = '';
      saved.forEach(hex => {
        const sw = document.createElement('div');
        sw.className = 'saved-swatch';
        sw.style.background = hex;
        sw.title = hex;
        sw.dataset.hex = hex;
        savedShelf.appendChild(sw);
      });
    }

    function getSaved() {
      try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? []; }
      catch { return []; }
    }

    // ── Events ───────────────────────────────────────────────
    document.querySelector('#gen-btn').addEventListener('click', () => setColor(randomHex()));
    preview.addEventListener('click', () => setColor(randomHex()));

    document.querySelector('#save-btn').addEventListener('click', () => {
      if (!current) return;
      const saved = getSaved();
      if (saved.includes(current)) { showToast('Already saved!'); return; }
      const updated = [current, ...saved].slice(0, 12);
      localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
      renderSaved();
      showToast('Saved!');
    });

    document.querySelector('#hex-box').addEventListener('click', () => copyText(hexVal.textContent));
    document.querySelector('#rgb-box').addEventListener('click', () => copyText(rgbVal.textContent));
    document.querySelector('#hsl-box').addEventListener('click', () => copyText(hslVal.textContent));

    palette.addEventListener('click', e => {
      const sw = e.target.closest('.swatch');
      if (sw) setColor(sw.dataset.hex);
    });

    savedShelf.addEventListener('click', e => {
      const sw = e.target.closest('.saved-swatch');
      if (sw) setColor(sw.dataset.hex);
    });

    // Init
    renderSaved();
    setColor(randomHex());
  </script>
</body>
</html>

Code Explained

Bitwise channel extraction

A HEX colour like #3B82F6 is 24 bits: 8 for red, 8 for green, 8 for blue. After parsing with parseInt(hex, 16) we get a single integer. Right-shifting by 16 (>> 16) moves the red bits to the low end, and & 0xFF masks off everything except the lowest 8 bits. The same mask without shifting extracts blue.

HSL for human-friendly palettes

HEX/RGB define colours as hardware channels. HSL (Hue, Saturation, Lightness) maps to human perception — Hue is the colour wheel position, Lightness is how light or dark. By keeping H and S constant and varying L from 20% to 80%, we get a set of shades that feel visually harmonious.

Clipboard API

navigator.clipboard.writeText() returns a Promise. It requires the page to be served over HTTPS (or localhost). Wrapping it in try/catch handles the case where the user denies clipboard permission.

Challenges

  1. Add a colour input (<input type="color">) so users can pick any colour and see its HEX/RGB/HSL values instantly.
  2. Generate complementary colours (opposite on the colour wheel: hue + 180°) and triadic colours (hue ± 120°).
  3. Add a contrast checker: given a background colour, calculate which text colour (black or white) has better WCAG contrast ratio.
  4. Add a gradient generator: pick two saved colours and output a CSS linear-gradient() string.
  5. Let users name their saved colours and export the palette as a CSS custom properties snippet.