DEV Community

Cover image for How to Pick a Color Palette That Survives Contact With Production
Ali Badshah
Ali Badshah

Posted on

How to Pick a Color Palette That Survives Contact With Production

#3C0D4F #F22C33 #C6EEA0 #1681DF
deep purple swatch #3C0D4F vivid red swatch #F22C33 light lime swatch #C6EEA0 cyan swatch #1681DF

🎨 Palette in playMango Garden Moment · #3C0D4F #F22C33 #C6EEA0 #1681DF

Picking colors is the part of a side project where an evening quietly disappears. You open a generator, spin the wheel forty times, save three screenshots, and end up shipping the same slate-and-indigo you always ship.

 

The problem is not taste. It is that a palette is chosen as a picture and used as a system. Four squares side by side always look balanced. The same four colors, applied to a heading, a body paragraph, a disabled button and a focus ring, very often do not.

 

Here is the process I use to go from "ooh, that one" to a theme that survives code review.

 

Section divider bar in deep purple

🧩 1 · Read the structure before the colors

Ignore whether you like it for a second and ask: what shape is this palette? Almost every four-color set falls into one of four structures, and the structure tells you what it can be used for.

Structure Ships well as Watch out for
Ramp — one hue, four lightnesses navy swatch #232E43 dark blue swatch #49627F muted blue swatch #7F95B1 light grey swatch #E8E9E5 a whole dashboard: bg, borders, muted text, text no accent — you must add one
Base + signals — one dark, three brights navy swatch #0F172A vivid teal swatch #00F5D4 vivid magenta swatch #F15BB5 indigo swatch #9B5DE5 dark mode with status colours brights are unreadable on white
Lights + anchor — three pales, one deep off-white swatch #F5F7F9 pale indigo swatch #E3DFF1 light magenta swatch #E0AED7 dark red swatch #994129 light UI with one strong CTA only one usable text colour
Near-monochrome — four neighbours light orange swatch #FFEAB6 light yellow swatch #FFEEBA light orange swatch #FFD489 vivid orange swatch #F9BA5D backgrounds, illustration, packaging no text can live here

That last row is the classic trap. The lightest and darkest of those four honeys are 1.45:1 apart. It is gorgeous, and it is a set of surfaces — bring your own ink or nobody can read your app.

 

Section divider bar in vivid red

🔬 2 · Test the pairs you will actually ship

A palette is not four colors; it is up to twelve pairings, and only a handful of them matter. Write down the pairs your UI really uses before you write any CSS:

  • body text on page background
  • heading on page background
  • button label on button fill
  • link on card background
  • border/icon on its surface

Then measure them. The formula is small enough to keep in the project:

const luminance = (hex) => {
  const [r, g, b] = hex.replace('#', '').match(/\w\w/g).map((h) => {
    const c = parseInt(h, 16) / 255;
    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};

export const contrast = (a, b) => {
  const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
  return +((x + 0.05) / (y + 0.05)).toFixed(2);
};

contrast('#3C0D4F', '#C6EEA0'); // 11.84  → AAA, ship it
contrast('#F22C33', '#FFFFFF'); // 4.05   → large or bold text only
contrast('#C6EEA0', '#FFFFFF'); // 1.30   → decorative surface, never text
Enter fullscreen mode Exit fullscreen mode

The thresholds that survive code review:

Ratio Meaning
4.5:1 body text — non-negotiable
3:1 24px+ text, 19px+ bold, plus borders, icons and focus rings
below 3:1 decoration only: backgrounds, dividers, illustrations, gradients

Run this on the example palette and it grades itself:

Swatch HEX vs #FFFFFF Verdict
deep purple swatch #3C0D4F #3C0D4F 15.41:1 ✅ text, headings, ink
vivid red swatch #F22C33 #F22C33 4.05:1 🟡 buttons and big type
light lime swatch #C6EEA0 #C6EEA0 1.30:1 🚫 surface only
cyan swatch #1681DF #1681DF 4.01:1 🟡 links at 18px+

Nothing here is "bad". The table just tells you which color is allowed to be a paragraph and which one is allowed to be a background — a decision you would otherwise make by accident.

 

Section divider bar in light lime

🏷️ 3 · Name the colors after their job

This is the step that separates a palette from a theme. Numbered variables are fine for copying and terrible for maintaining:

  :root {
-   --palette-color-1: #3C0D4F;
-   --palette-color-2: #F22C33;
-   --palette-color-3: #C6EEA0;
-   --palette-color-4: #1681DF;
+   --color-ink:      #3C0D4F;  /* headings, body text        */
+   --color-primary:  #F22C33;  /* buttons, active states     */
+   --color-surface:  #C6EEA0;  /* cards, highlight sections  */
+   --color-link:     #1681DF;  /* links, info, focus rings   */
+   --color-page:     #FFFFFF;
  }
Enter fullscreen mode Exit fullscreen mode

Now components consume intentions, never hexes:

.card {
  background: var(--color-surface);
  color: var(--color-ink);
  border: 2px solid var(--color-ink);
  border-radius: 14px;
  padding: 1.5rem;
}

.card__cta {
  background: var(--color-primary);
  color: #fff;
  border-radius: 999px;
  padding: 0.7rem 1.4rem;
}

.card a {
  color: var(--color-link);
  text-underline-offset: 3px;
}
Enter fullscreen mode Exit fullscreen mode

#3C0D4F on #C6EEA0 is 11.84:1, so that card is readable before you even open a checker. And when someone inevitably says "can we try a different palette?", it is one file, not forty components.

 

🧰 The same four colors as Tailwind config and design tokens
// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        ink:     '#3C0D4F',
        primary: '#F22C33',
        surface: '#C6EEA0',
        link:    '#1681DF',
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode
{
  "palette": {
    "ink":     { "$type": "color", "$value": "#3C0D4F" },
    "primary": { "$type": "color", "$value": "#F22C33" },
    "surface": { "$type": "color", "$value": "#C6EEA0" },
    "link":    { "$type": "color", "$value": "#1681DF" }
  }
}
Enter fullscreen mode Exit fullscreen mode

 

Section divider bar in cyan

🧠 4 · Four strategies for actually choosing

Start from the mood. Decide the adjective first — calm, expensive, playful, technical — and search for that word rather than for colors. It is much easier to reject a palette that does not feel "calm" than to compare two blues.

 

Start from the temperature. Warm (yellow-based) palettes read friendly, human, editorial. Cool (blue-based) palettes read precise, clinical, trustworthy. Getting this wrong is why some fintech dashboards feel like a smoothie brand.

 

Start from a palette you like and walk sideways. Found something close but the contrast is wrong? Nudge one color's lightness by 15% and re-measure instead of starting over. "Same feeling, better legibility" is nearly always two tweaks away.

 

Start from what your UI actually needs. Count the roles before you count the colors:

$ what does this UI need?
  1 page background
  1 card/surface
  1 ink (text)          ← must clear 4.5:1 on both of the above
  1 primary action
  1 link colour
  3 status colours      ← success / warning / danger
= 8 slots, and your palette gave you 4
Enter fullscreen mode Exit fullscreen mode

That is the honest math. A four-color palette is a starting point — you will derive the rest by tinting, shading and mixing:

:root {
  --color-primary: #F22C33;
  /* derived, not invented */
  --color-primary-hover:   color-mix(in srgb, var(--color-primary) 85%, black);
  --color-primary-subtle:  color-mix(in srgb, var(--color-primary) 12%, white);
  --color-primary-border:  color-mix(in srgb, var(--color-primary) 40%, transparent);
}
Enter fullscreen mode Exit fullscreen mode

 

Section divider bar in vivid teal

🧪 5 · Three quick sanity checks before you commit

Squint test. Blur your eyes at the mockup. If everything mushes into one grey blob, your palette has no contrast hierarchy — a common outcome of very pretty, very muted palettes.

 

Greyscale test. Screenshot the UI and desaturate it. Buttons should still look like buttons. If two states are identical in greyscale, you are encoding meaning in hue alone, which fails for colorblind users.

 

One-color-removed test. Delete the accent and look again. If the interface collapses into unusable, you are leaning on decoration to do structural work.

$ checklist
  [ ] body text ≥ 4.5:1 on every surface it appears on
  [ ] focus ring visible on light AND dark backgrounds
  [ ] status colours distinguishable in greyscale
  [ ] one text colour that works on the two most common surfaces
  [ ] hover/active states derived, not hand-picked
Enter fullscreen mode Exit fullscreen mode

 

Section divider bar in vivid magenta

🧾 The short version

  1. Identify the structure — ramp, base+signals, lights+anchor, or near-monochrome.
  2. List the pairs your UI ships, and measure only those.
  3. Rename --palette-color-3 into --color-surface; components never see hexes.
  4. Expect a four-color palette to fill about half your slots; derive the rest with color-mix().
  5. Squint, desaturate, and remove the accent before you call it done.

Do that and colour stops being the thing that eats an evening — it becomes four lines in a token file that you can swap in a minute.

 

If you want a shortcut to step one, ColorFiind is a browsable library of 34,000+ named palettes — every one has its HEX codes, a ready-made :root block and a downloadable swatch image, sorted by category and by season.

 

Browse 34,000+ colour palettes

 

What's the palette you're using right now? Drop the four hexes in the comments and I'll tell you which pair is quietly failing. 👇

Top comments (0)