Intermediate1.5 hoursHTMLCSSJavaScript

Build a Colour Palette Generator with JavaScript

Generate random harmonious palettes, lock favourites, copy HEX codes and press Space for new colours.

What you'll build

Live demo: the finished colour palette generator. Try it.
<header class="bar">
  <h1>Palette</h1>
  <button id="generate">Generate (Space)</button>
</header>
<main id="palette"></main>
ResultEdit the code, then press Run or Ctrl+Enter

Designers love tools like Coolors that generate colour palettes at the press of a key. You'll build your own: five colour swatches, a lock on each so you can keep the ones you like, click-to-copy HEX codes, and the space bar to roll new colours.

You'll practise random numbers, HSL colours, arrays of state, event delegation and the Clipboard API.

Why HSL makes better palettes

The quickest way to make a random colour is to pick three random numbers for red, green and blue. The results are usually ugly, because RGB doesn't match how people think about colour. Five random RGB colours rarely look like they belong together.

HSL describes colour the way a painter would: a hue from 0 to 360 around the colour wheel, a saturation for how vivid it is, and a lightness from black to white. That makes harmony easy. Pick one random starting hue, then space the other colours at regular steps around the wheel and keep saturation and lightness within a pleasant range. The colours vary but still feel related. The CSS colors lesson shows HSL alongside HEX and RGB.

Designers share colours as HEX codes, so the app converts each HSL colour to HEX before showing it. That conversion is a small piece of maths you can copy and reuse in other projects.

Keeping state in an array

Like the to-do app, this project keeps its data in an array and redraws the swatches from it. Each swatch is an object with its colour and whether it's locked:

JavaScript
{ hex: "#3a7bd5", locked: false }

When you generate a new palette, the code walks through the array and only replaces colours that aren't locked. The page is then rendered again. Keeping the locked flag in the data, rather than reading it back from the button on screen, keeps the logic simple and predictable. The arrays lesson covers the methods used here.

Making the layout work on every screen

Five tall swatches side by side look great on a laptop, but on a phone each one would be a thin sliver. CSS Grid handles both cases with very little code. On wide screens, grid-template-columns: repeat(5, 1fr) gives five equal columns that fill the width. Inside a media query for small screens, you switch to a single column so the swatches stack as full-width bands that are easy to tap. The CSS grid lesson and the media queries lesson cover both techniques.

A short transition on the background colour makes each new palette fade in rather than snap, which feels much more polished for one line of CSS. Keep it brief, around 200 to 300 milliseconds, so pressing Space still feels instant.

How HSL to HEX conversion works

Browsers understand HSL directly, so why convert at all? Because designers, design tools and most style guides share colours as HEX codes such as #3b82f6. A HEX code is just the red, green and blue values written in base 16: two characters for each channel, from 00 (none) to ff (full, which is 255).

The conversion happens in two stages. First, HSL is turned into red, green and blue values between 0 and 255 using a standard formula that works out which part of the colour wheel the hue falls in. Second, each value is converted to a two-character hexadecimal string:

JavaScript
const toHex = (n) => Math.round(n).toString(16).padStart(2, "0");

toHex(59);   // "3b"
toHex(130);  // "82"
toHex(246);  // "f6"
// "#" + "3b" + "82" + "f6" gives "#3b82f6"

toString(16) converts a number to base 16, and padStart makes sure a small value like 9 becomes "09" rather than "9", so every code is exactly six characters. The same helper is useful any time you build a random colour generator or a colour picker.

Check colour contrast for readable text

The app picks dark or light text for each swatch, but how do you know the result is readable? The Web Content Accessibility Guidelines (WCAG) define a contrast ratio between text and its background, from 1:1 (no contrast) to 21:1 (black on white). Normal text should reach at least 4.5:1, and large text at least 3:1.

The quick brightness check in this project gets most colours right. For a precise answer, calculate each colour's relative luminance and compare them with the WCAG formula, or paste the HEX codes into any online contrast checker. Building contrast checking into your palette tool, and showing a warning when a swatch fails, is an excellent bonus feature and a skill that real design teams value.

Requirements checklist

  • Five swatches that fill the screen side by side (stacked on phones)
  • Each swatch shows its HEX code; clicking the code copies it
  • A lock button on each swatch; locked colours don't change
  • Press Space or the Generate button for a new palette
  • Text colour adapts (dark text on light colours, light text on dark ones)
  • Palettes look harmonious, not completely random

Starter code

Press Run to see where you're starting from, then follow the steps.

// Your palette code
ResultEdit the code, then press Run or Ctrl+Enter

Step-by-step guide

  1. 1

    Keep the palette in an array

    Each swatch is an object: its colour and whether it's locked.

    JavaScript
    const palette = Array.from({ length: 5 }, () => ({ color: "", locked: false }));
    const container = document.querySelector("#palette");
  2. 2

    Generate harmonious colours with HSL

    Pick one random base hue, then spread the others around it. Same saturation family = colours that go together.

    JavaScript
    const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
    
    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) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
      const hex = (x) => Math.round(x * 255).toString(16).padStart(2, "0");
      return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
    }
    
    function generate() {
      const base = rand(0, 359);
      palette.forEach((swatch, i) => {
        if (swatch.locked) return;
        const hue = (base + i * rand(18, 40)) % 360;
        swatch.color = hslToHex(hue, rand(55, 85), rand(35, 80));
      });
      render();
    }
  3. 3

    Pick readable text colours

    Light backgrounds need dark text. A quick brightness check using the RGB values works well:

    JavaScript
    function isLight(hex) {
      const r = parseInt(hex.slice(1, 3), 16);
      const g = parseInt(hex.slice(3, 5), 16);
      const b = parseInt(hex.slice(5, 7), 16);
      return (r * 299 + g * 587 + b * 114) / 1000 > 150;
    }
  4. 4

    Render the swatches

    JavaScript
    function render() {
      container.replaceChildren();
      palette.forEach((swatch, i) => {
        const div = document.createElement("div");
        div.className = "swatch";
        div.dataset.index = i;
        div.style.background = swatch.color;
        div.style.color = isLight(swatch.color) ? "#15161b" : "#ffffff";
        div.innerHTML = `
          <button class="hex" title="Copy"></button>
          <button class="lock" aria-pressed="${swatch.locked}">${swatch.locked ? "πŸ”’" : "πŸ”“"}</button>`;
        div.querySelector(".hex").textContent = swatch.color.toUpperCase();
        container.append(div);
      });
    }
  5. 5

    Lock, copy and keyboard shortcuts

    One delegated click listener handles both buttons on every swatch.

    JavaScript
    container.addEventListener("click", async (e) => {
      const swatchEl = e.target.closest(".swatch");
      if (!swatchEl) return;
      const swatch = palette[swatchEl.dataset.index];
    
      if (e.target.matches(".lock")) {
        swatch.locked = !swatch.locked;
        render();
      } else if (e.target.matches(".hex")) {
        try {
          await navigator.clipboard.writeText(swatch.color);
          e.target.textContent = "Copied!";
        } catch {
          e.target.textContent = swatch.color; // clipboard blocked: show it to copy by hand
        }
        setTimeout(render, 900);
      }
    });
    
    document.addEventListener("keydown", (e) => {
      if (e.code === "Space" && e.target === document.body) {
        e.preventDefault();
        generate();
      }
    });
    
    document.querySelector("#generate").addEventListener("click", generate);
    generate();

    navigator.clipboard.writeText returns a promise, so we await it and catch the error if the browser blocks it.

  6. 6

    Lay it out with Grid

    CSS
    .bar { display: flex; justify-content: space-between; align-items: center; padding: 10px 16px; background: #15161b; color: white; }
    .bar h1 { margin: 0; font-size: 1.2rem; }
    #generate { padding: 8px 14px; border: 0; border-radius: 999px; background: #ffd23f; font-weight: 700; cursor: pointer; }
    
    #palette {
      display: grid;
      grid-template-columns: repeat(5, 1fr);
      height: calc(100vh - 56px);
    }
    
    .swatch { display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 10px; padding-bottom: 30px; transition: background 0.3s; }
    .swatch button { border: 0; background: rgb(255 255 255 / 25%); color: inherit; font: 700 1rem monospace; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
    
    @media (max-width: 600px) {
      #palette { grid-template-columns: 1fr; }
      .swatch { flex-direction: row; justify-content: space-between; padding: 0 16px; }
    }

Final solution

Show the complete solution
const palette = Array.from({ length: 5 }, () => ({ color: "", locked: false }));
const container = document.querySelector("#palette");

const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;

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) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  const hex = (x) => Math.round(x * 255).toString(16).padStart(2, "0");
  return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
}

function isLight(hex) {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return (r * 299 + g * 587 + b * 114) / 1000 > 150;
}

function generate() {
  const base = rand(0, 359);
  palette.forEach((swatch, i) => {
    if (swatch.locked) return;
    const hue = (base + i * rand(18, 40)) % 360;
    swatch.color = hslToHex(hue, rand(55, 85), rand(35, 80));
  });
  render();
}

function render() {
  container.replaceChildren();
  palette.forEach((swatch, i) => {
    const div = document.createElement("div");
    div.className = "swatch";
    div.dataset.index = i;
    div.style.background = swatch.color;
    div.style.color = isLight(swatch.color) ? "#15161b" : "#ffffff";
    div.innerHTML = `
      <button class="hex" title="Copy"></button>
      <button class="lock" aria-pressed="${swatch.locked}">${swatch.locked ? "πŸ”’" : "πŸ”“"}</button>`;
    div.querySelector(".hex").textContent = swatch.color.toUpperCase();
    container.append(div);
  });
}

container.addEventListener("click", async (e) => {
  const swatchEl = e.target.closest(".swatch");
  if (!swatchEl) return;
  const swatch = palette[swatchEl.dataset.index];

  if (e.target.matches(".lock")) {
    swatch.locked = !swatch.locked;
    render();
  } else if (e.target.matches(".hex")) {
    try {
      await navigator.clipboard.writeText(swatch.color);
      e.target.textContent = "Copied!";
    } catch {
      e.target.textContent = swatch.color;
    }
    setTimeout(render, 900);
  }
});

document.addEventListener("keydown", (e) => {
  if (e.code === "Space" && e.target === document.body) {
    e.preventDefault();
    generate();
  }
});

document.querySelector("#generate").addEventListener("click", generate);
generate();
ResultEdit the code, then press Run or Ctrl+Enter

Bonus challenges

  • Save favourite palettes to localStorage and show them in a sidebar.
  • Add a "Shades" button that turns one colour into five lighter/darker versions.
  • Export the palette as CSS variables (--color-1: #…) ready to paste into a project.

Common mistakes and how to fix them

Pressing Space scrolls the page. The browser's default action for Space is to scroll. Call event.preventDefault() inside your keydown handler, but only for the Space key, so other keys keep working.

Pressing Space while a button has focus generates twice. A focused button also "clicks" when you press Space. Ignore the keydown when the target is a button, or blur buttons after clicking.

Copy to clipboard fails. navigator.clipboard.writeText only works on secure pages (https or localhost) and returns a promise. Use await and catch errors so you can show a friendly message. The async and await lesson explains promises.

White text is unreadable on yellow. Choose the text colour from the swatch's lightness: dark text when lightness is above about 60%, light text otherwise.

Check your work

  1. Lock two colours and press Space ten times. The locked colours should never change.
  2. Click a HEX code and paste it into a text editor. It should match exactly, including the #.
  3. Narrow the window to phone width. The swatches should stack vertically and stay tappable.
  4. Use the app with only a keyboard: Tab to each lock button and copy button, then press Space on the page to generate.

Colour harmony rules you can add

Professional palette generators offer several colour harmony modes, and each one is a different rule for spacing hues around the 360-degree wheel:

  • Analogous: neighbouring hues, about 30 degrees apart. Calm and natural, like a sunset.
  • Complementary: two hues opposite each other, 180 degrees apart. High contrast and energetic.
  • Triadic: three hues spaced 120 degrees apart. Balanced and colourful.
  • Monochromatic: one hue with different lightness values. Clean and minimal.

Because your generator already works in HSL, each mode is just a different list of offsets added to the base hue, such as [0, 120, 240] for triadic. Add a dropdown that switches between them and your project becomes a genuinely useful design tool.

Questions beginners ask

Is this how real palette tools work? The idea is the same. Professional tools add more harmony rules, such as complementary colours (opposite on the wheel) and triads (three evenly spaced hues), which you can add as an extra feature.

Can I use these palettes in my own sites? Yes. Copy the HEX codes into CSS variables and your whole site can switch colour schemes by changing five lines.

What's a good next step? Save favourite palettes with localStorage (the first bonus challenge), then revisit the CSS grid lesson to design a gallery that shows your saved palettes.