Choosing colors (for developers)

The eleven-shade color scale from Tailwind CSS is one of those ideas I keep stealing. You know the one: 50 through 950 gives you enough stops for backgrounds, borders, hover states, text, everything. I use it even when I’m not using Tailwind.

But I almost never use the defaults (your site/app ends up looking like every other site built by LLMs).

The fix is not complicated. You don’t need a color theory degree or a five-color palette generator (you know the ones 🥹). You only need two or three color scales, and you can derive them all from a single value.

How to choose your brand hue

Rule of thumb: pick a color that fits the problem, not your personal taste (I like pink but not use it all the time).

  • Finance, B2B and enterprise sites use blues (hue 200-250).
  • Trust, stability, conservative. Health and wellness lean green or teal (120-200). Calm, natural.
  • Creative and entertainment go purple, pink, orange (270-360). Energy, fun.
  • Food and hospitality use reds, oranges, warm yellows (0-60). Appetite, warmth.

These are guidelines, not rules. A creative agency can use blue. A bank can use orange. But the defaults exist for a reason. They match user expectations.

Still stuck? Steal from a site you like. Find an app or landing page whose color feels right for your domain. Open DevTools, grab the hue from any OKLCH color on the page. That is your starting point. Adjust from there.

If you have an image (hero photo, logo, brand asset), sample a prominent color from it. Drop it into a color picker, get the OKLCH hue. Images that already look good together contain colors that already work together.

You do not need to nail it on the first try. Pick something reasonable, build the palette, see it on a real page. If it feels wrong, shift the hue by 30 and check again. Five minutes of tweaking beats an hour of analysis. 💪

Pick a neutral, name it --gray

First, a naming trick. Whatever neutral you settle on, alias it to --gray-*. This way your templates reference --gray-500 regardless of which tone you picked.

:root {
  --gray-50: #f8fafc;
  --gray-100: #f1f5f9;
  --gray-200: #e2e8f0;
  /**/
}

The point is the name --gray stays the same. Want a warmer tone next month? Change the values, not every template.

If I use Tailwind, I use the same trick (but map them with the --color- prefix so bg-gray-500 works):

@theme {
  --color-gray-50: var(--gray-50);
  --color-gray-100: var(--gray-100);
}

One hue to rule them

OKLCH makes this easy. Instead of hardcoding eleven hex values for your brand, derive them from a single hue variable.

I covered the technique before in Building user-customizable themes with Tailwind CSS. The idea: OKLCH separates color into lightness, chroma and hue. Keep lightness and chroma constant per shade level, vary only the hue.

:root {
  --brand-hue: 250;

  --brand-50: oklch(.99 .01 var(--brand-hue));
  --brand-100: oklch(.98 .02 var(--brand-hue));
  --brand-200: oklch(.94 .04 var(--brand-hue));
  --brand-300: oklch(.86 .08 var(--brand-hue));
  --brand-400: oklch(.74 .14 var(--brand-hue));
  --brand-500: oklch(.6 .18 var(--brand-hue));
  --brand-600: oklch(.52 .16 var(--brand-hue));
  --brand-700: oklch(.44 .14 var(--brand-hue));
  --brand-800: oklch(.36 .12 var(--brand-hue));
  --brand-900: oklch(.26 .08 var(--brand-hue));
  --brand-950: oklch(.16 .04 var(--brand-hue));
}

Change --brand-hue from 250 to 10 and every shade updates. The palette stays harmonious because lightness and chroma never shift.

One thing to watch: OKLCH math gets you 90% of the way there. Different hues at the same lightness and chroma can feel slightly different to the eye — a purple at .6 .18 reads differently than a yellow at .6 .18. You might need to bump a stop or two for balance. That is normal. Tweak and move on.

Gray from your brand tint

Create a neutral palette the same way. Drop the chroma near zero:

:root {
  --gray-50: oklch(.99 .005 var(--brand-hue));
  --gray-100: oklch(.97 .005 var(--brand-hue));
  --gray-200: oklch(.93 .005 var(--brand-hue));
  --gray-300: oklch(.86 .008 var(--brand-hue));
  --gray-400: oklch(.76 .01 var(--brand-hue));
  --gray-500: oklch(.64 .01 var(--brand-hue));
  --gray-600: oklch(.54 .01 var(--brand-hue));
  --gray-700: oklch(.44 .01 var(--brand-hue));
  --gray-800: oklch(.34 .008 var(--brand-hue));
  --gray-900: oklch(.24 .005 var(--brand-hue));
  --gray-950: oklch(.14 .005 var(--brand-hue));
}

These read as neutral grays. But they carry a faint undertone of your brand color. The result is cohesive without being matchy-matchy.

Get more than 200 Rails UI Components

Rails Designer's UI components is an UI components libraray used by 1,000+ developers globally to build their next project.

Get it now

Color theory with math

Complementary colors sit opposite each other on the color wheel. In OKLCH that is + 180 degrees.

:root {
  --accent-50: oklch(.99 .01 calc(var(--brand-hue) + 180));
  --accent-100: oklch(.98 .02 calc(var(--brand-hue) + 180));
  --accent-200: oklch(.94 .04 calc(var(--brand-hue) + 180));
  --accent-300: oklch(.86 .08 calc(var(--brand-hue) + 180));
  --accent-400: oklch(.74 .14 calc(var(--brand-hue) + 180));
  --accent-500: oklch(.6 .18 calc(var(--brand-hue) + 180));
  --accent-600: oklch(.52 .16 calc(var(--brand-hue) + 180));
  --accent-700: oklch(.44 .14 calc(var(--brand-hue) + 180));
  --accent-800: oklch(.36 .12 calc(var(--brand-hue) + 180));
  --accent-900: oklch(.26 .08 calc(var(--brand-hue) + 180));
  --accent-950: oklch(.16 .04 calc(var(--brand-hue) + 180));
}

That is the simplest color scheme: complementary. High contrast, punches hard.

But you have more options. Here is how color schemes translate to OKLCH math:

  • Analogous — hues next to your brand (± 30). Low contrast, harmonious. Good for backgrounds and subtle variety.
  • Complementary — opposite hue (+ 180). High contrast, pops. Good for CTAs and accents.
  • Split complementary — the two hues on either side of the complement (+ 150 and - 150). Same contrast as complementary but safer. Less jarring.
  • Triadic — three hues evenly spaced (+ 120 and + 240). Balanced. Gives you a third color for free.
--analogous-light: calc(var(--brand-hue) + 30);
--analogous-dark: calc(var(--brand-hue) - 30);
--split-a: calc(var(--brand-hue) + 150);
--split-b: calc(var(--brand-hue) - 150);
--triad-a: calc(var(--brand-hue) + 120);
--triad-b: calc(var(--brand-hue) + 240);

Pick one scheme and stick with it. The math guarantees the relationship. You just pick the starting hue.

All together now

:root {
  --brand-hue: 250;

  /* Gray (low chroma, brand undertone) */
  --gray-50: oklch(.99 .005 var(--brand-hue));
  --gray-100: oklch(.97 .005 var(--brand-hue));
  --gray-200: oklch(.93 .005 var(--brand-hue));
  --gray-300: oklch(.86 .008 var(--brand-hue));
  --gray-400: oklch(.76 .01 var(--brand-hue));
  --gray-500: oklch(.64 .01 var(--brand-hue));
  --gray-600: oklch(.54 .01 var(--brand-hue));
  --gray-700: oklch(.44 .01 var(--brand-hue));
  --gray-800: oklch(.34 .008 var(--brand-hue));
  --gray-900: oklch(.24 .005 var(--brand-hue));
  --gray-950: oklch(.14 .005 var(--brand-hue));

  /* Brand */
  --brand-50: oklch(.99 .01 var(--brand-hue));
  --brand-100: oklch(.98 .02 var(--brand-hue));
  --brand-200: oklch(.94 .04 var(--brand-hue));
  --brand-300: oklch(.86 .08 var(--brand-hue));
  --brand-400: oklch(.74 .14 var(--brand-hue));
  --brand-500: oklch(.6 .18 var(--brand-hue));
  --brand-600: oklch(.52 .16 var(--brand-hue));
  --brand-700: oklch(.44 .14 var(--brand-hue));
  --brand-800: oklch(.36 .12 var(--brand-hue));
  --brand-900: oklch(.26 .08 var(--brand-hue));
  --brand-950: oklch(.16 .04 var(--brand-hue));

  /* Accent (complementary) */
  --accent-50: oklch(.99 .01 calc(var(--brand-hue) + 180));
  --accent-100: oklch(.98 .02 calc(var(--brand-hue) + 180));
  --accent-200: oklch(.94 .04 calc(var(--brand-hue) + 180));
  --accent-300: oklch(.86 .08 calc(var(--brand-hue) + 180));
  --accent-400: oklch(.74 .14 calc(var(--brand-hue) + 180));
  --accent-500: oklch(.6 .18 calc(var(--brand-hue) + 180));
  --accent-600: oklch(.52 .16 calc(var(--brand-hue) + 180));
  --accent-700: oklch(.44 .14 calc(var(--brand-hue) + 180));
  --accent-800: oklch(.36 .12 calc(var(--brand-hue) + 180));
  --accent-900: oklch(.26 .08 calc(var(--brand-hue) + 180));
  --accent-950: oklch(.16 .04 calc(var(--brand-hue) + 180));
}

Use them anywhere:

.btn--primary {
  background: var(--brand-500);

  &:hover {
    background: var(--brand-600);
  }
}

.card {
  background: var(--gray-50);
  border: 1px solid var(--gray-100);
}

.badge--new {
  color: var(--accent-700);
  background: var(--accent-100);
}

Three palettes. One variable to change them all. No hex codes to manage. Winning! 🏆

Above :root{} content is easily copied from one app or site to your next one. Making sure you can return building quickly.

Product-minded Rails notes

Once a month: straightforward notes on improving UX in Rails—what to simplify, what to measure, and UI/frontend changes that move real usage.

Over to you…

What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!

Comments are powered by Chirp Form

Want to read me more?