OneMinuteBrandingOneMinuteBrandingGenerate Brand
  1. Home
  2. Blog
  3. Color Theory for Engineers: The Only 5 Rules You Need
color theorydesigndevelopers

Color Theory for Engineers: The Only 5 Rules You Need

Skip the art school lecture. These 5 rules cover 90% of color decisions you'll ever make for a web app. With code examples.

March 15, 20269 min readBy Yann Lephay

You've been dragging the saturation slider in Figma for 45 minutes. Your SaaS dashboard currently uses 14 different shades of blue. You have a Postgres database to schema and an Auth0 integration to finish, but you are blocked because your primary button looks like a cheap plastic toy. Design isn't magic. It's math, constraints, and predictable systems.

The Infinite Palette Trap

Developers fail at color because we treat it like a limitless variable. You open a color picker, see 16.7 million hex codes, and try to guess. You pick #3B82F6 for a button, realize the text is illegible, switch the text to #FFFFFF, and then realize the hover state needs to be darker. Three hours later, your tailwind.config.ts has 42 custom color definitions grouped under names like brand-blue-darker-alt.

A production web app needs exactly three things from a color system: a neutral scale for structure, a primary scale for action, and semantic scales for state (success, error, warning). Every additional color you add exponentially increases the chance of a UI clash. To fix this, you only need to enforce 5 mechanical rules.

1. Map 60-30-10 Directly to the DOM

Interior designers use the 60-30-10 rule: 60% dominant color, 30% secondary color, 10% accent color. In UI development, this maps exactly to your DOM hierarchy and Tailwind utility classes.

60% is your background and surface area. This is your foundation. 30% is your structural elements. This includes borders, secondary text, and inactive cards. 10% is your interactive layer. This is strictly reserved for primary buttons, active tabs, and focus rings.

When your UI looks amateur, it's usually because your accent color is consuming 40% of the viewport. A dashboard with a solid purple sidebar, purple header, and purple buttons violates the math. Your user's eye doesn't know where to click because everything is screaming for attention.

Here is how you strictly enforce 60-30-10 in a standard layout component:

Code
export default function DashboardLayout({ children }) {
  return (
    // 60%: The dominant background (bg-zinc-50)
    <div className="min-h-screen bg-zinc-50 font-sans text-zinc-900">
      
      {/* 30%: Structural elements, borders, and secondary surfaces */}
      <aside className="w-64 border-r border-zinc-200 bg-white p-4">
        <nav className="flex flex-col gap-2">
          {/* Secondary text and subtle hover states */}
          <a href="#" className="text-zinc-600 hover:bg-zinc-100 p-2 rounded-md">
            Settings
          </a>
        </nav>
      </aside>
 
      <main className="flex-1 p-8">
        <header className="mb-8 flex justify-between items-center">
          <h1 className="text-2xl font-bold">Overview</h1>
          
          {/* 10%: The accent color, strictly used for primary action */}
          <button className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
            Create Project
          </button>
        </header>
        
        {/* 30%: Cards and structural borders */}
        <div className="border border-zinc-200 bg-white rounded-xl shadow-sm p-6">
          {children}
        </div>
      </main>
    </div>
  )
}
If you catch yourself applying `bg-indigo-600` to a container that occupies more than 10% of the screen real estate, you are breaking the rule. Revert it to a neutral surface and use the accent color only for the specific action within that container.
 
## 2. Enforce Contrast Ratios Mathematically
 
Stop squinting at your monitor to check if gray text is readable. Accessibility is a strict mathematical threshold. The Web Content Accessibility Guidelines (WCAG) dictate a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text (18pt or 14pt bold).
 
If you put `#FFFFFF` text on a `#FBBF24` (Tailwind Amber 400) button, your contrast ratio is 1.28:1. That is functionally invisible to a user with astigmatism. If you use `#6B7280` (Gray 500) for placeholder text on a `#FFFFFF` background, the ratio is 4.54:1. You barely pass.
 
You do not need to guess these values. You need to memorize the safe pairings within a standard 11-step scale (50-950).
 
| Foreground Class | Background Class | Contrast Ratio | WCAG Status | Use Case |
| :--- | :--- | :--- | :--- | :--- |
| `text-zinc-900` | `bg-zinc-50` | 15.8:1 | Pass (AAA) | Primary body text |
| `text-zinc-500` | `bg-white` | 4.54:1 | Pass (AA) | Secondary text, placeholders |
| `text-zinc-400` | `bg-white` | 2.95:1 | **Fail** | Do not use for text |
| `text-white` | `bg-blue-600` | 5.1:1 | Pass (AA) | Primary buttons |
| `text-white` | `bg-blue-500` | 3.6:1 | **Fail** (Normal text) | Large text only (18pt+) |
 
To enforce this programmatically, use a tool like `eslint-plugin-jsx-a11y` or add a contrast-checking utility to your CI pipeline. If your primary brand color sits at the 400 or 500 level (like a bright cyan or lime), you cannot use white text on your primary buttons. You must use `text-slate-900` to hit the 4.5:1 threshold.
 
## 3. Limit HSL Ranges for Primary Colors
 
Engineers often pick primary colors by dragging the color picker to the extreme top-right corner. This yields pure, fully saturated hex codes like `#FF0000` or `#00FF00`. These colors cause eye strain because LCD monitors render them at maximum light intensity. They look like terminal output, not a modern SaaS product.
 
Professional UI colors live in a specific HSL (Hue, Saturation, Lightness) bounding box. To pick a primary color that looks premium, constrain your values:
 
- **Saturation:** 60% to 85%. Never 100%. You want color, not a laser beam.
- **Lightness:** 45% to 55%. This is the sweet spot where the color is dark enough to support white text (Rule 2), but bright enough to register as a color rather than a muddy gray.
 
If you generate a brand identity with OneMinuteBranding, the AI strictly enforces these bounding boxes. The engine outputs a `$49 one-time` package that includes a `tailwind.config.ts` where the primary color is mathematically guaranteed to hit the 45-55% lightness target, ensuring your `bg-primary` and `text-white` combination always passes WCAG requirements.
 
## 4. Dark Mode Requires Desaturation, Not Just Inversion
 
Flipping `bg-white` to `bg-black` takes 10 seconds. Fixing the neon bleed of your primary colors takes hours.
 
When you put `indigo-600` (`#4F46E5`) on a white background, it looks grounded. When you put that exact same hex code on `zinc-950` (`#09090B`), the optical contrast spikes. The color appears to vibrate. This is called halation.
 
Dark mode requires desaturating your primary colors by 10-20% and increasing lightness by 5-10%. You cannot use the exact same hex code for your primary button in both modes.
 
Here is the correct way to handle dark mode using CSS variables. You define the base values in the `:root` and modify the specific channels in the `.dark` class.
 
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
 
@layer base {
  :root {
    /* Light mode: 50% lightness, 80% saturation */
    --color-primary: 240 80% 50%;
    
    --bg-surface: 0 0% 100%;
    --text-main: 240 10% 10%;
  }
 
  .dark {
    /* Dark mode: 60% lightness (brighter), 60% saturation (less intense) */
    --color-primary: 240 60% 60%;
    
    --bg-surface: 240 10% 4%;
    --text-main: 0 0% 98%;
  }
}
Code
// tailwind.config.ts
import type { Config } from 'tailwindcss'
 
export default {
  content: ['./app/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        primary: 'hsl(var(--color-primary) / <alpha-value>)',
        surface: 'hsl(var(--bg-surface) / <alpha-value>)',
        main: 'hsl(var(--text-main) / <alpha-value>)',
      },
    },
  },
} satisfies Config

By passing the raw HSL channels to Tailwind via CSS variables, you allow Tailwind's opacity modifier (bg-primary/50) to continue working, while safely dampening the color intensity for dark mode users.

5. Generate 11-Step Scales via OKLCH

A single hex code is useless. You need an 11-step scale (50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950) to handle backgrounds, borders, hover states, and active states.

RGB and HSL fail at scale generation. If you linearly interpolate lightness in HSL (e.g., dropping lightness by 10% for each step), the perceived brightness jumps erratically. Yellows get washed out immediately, while blues turn to pure black by step 700. HSL is a geometric model; it does not map to human visual perception.

You must use the OKLCH color space. OKLCH separates perceived lightness (L), chroma/intensity (C), and hue (H). A 10% shift in OKLCH lightness looks exactly like a 10% shift to the human eye, regardless of whether the hue is yellow or purple.

Writing an OKLCH interpolator from scratch takes a full day of reading color science documentation. You have to handle gamut clipping (when a color exists in OKLCH but cannot be rendered by an sRGB monitor) and chroma smoothing.

Instead of building this yourself, you can use OneMinuteBranding. For a $49 one-time payment, it takes a single input, runs the OKLCH interpolation math, handles the gamut clipping, and generates the full 50-950 scale. In 60 seconds, you receive a production-ready tailwind.config.ts, all necessary CSS variables, and a CLAUDE.md file so your AI cursor knows exactly which utility classes to use.

If you insist on doing it manually, you need to use the colorjs.io library to handle the OKLCH to sRGB fallback conversions:

Code
import Color from "colorjs.io";
 
// Base primary color in OKLCH
const baseColor = new Color("oklch(0.65 0.2 250)");
 
// Generate the 900 shade by dropping lightness to 0.25
const shade900 = baseColor.clone();
shade900.oklch.l = 0.25;
 
// Convert back to sRGB hex for Tailwind, clipping out-of-gamut colors
const hex900 = shade900.to("srgb").toString({ format: "hex" });
console.log(hex900); // Maps to your tailwind config

You run this loop 11 times, mapping lightness values from 0.98 (for the 50 shade) down to 0.15 (for the 950 shade). Maintain a constant hue, but taper the chroma at the extreme light and dark ends so your bg-50 doesn't look tinted and your text-950 remains highly legible.

FAQ

Should I use RGB, HEX, HSL, or OKLCH in my CSS?

Use OKLCH. It is natively supported in all modern browsers (Chrome 111+, Safari 16.2+). It allows you to manipulate opacity natively oklch(0.65 0.2 250 / 0.5) and ensures your lightness values are perceptually uniform. If you need to support legacy browsers, use HSL variables. Never use HEX in modern CSS; it prevents you from using alpha channel modifiers dynamically.

How many colors do my web app actually need?

Three scales. One neutral scale (slate, zinc, or gray) for 90% of your UI. One primary scale for your brand and primary actions. One semantic set (red for destructive, green for success, amber for warnings). If your design system has a "secondary" or "tertiary" brand color, delete them. They are confusing your users.

What about gradients?

Keep them subtle and mathematically constrained. Limit your hue shift to a maximum of 15 degrees. If your starting color is oklch(0.6 0.2 250), your ending color should be oklch(0.6 0.2 265). Shifting 180 degrees across the color wheel creates muddy, gray dead zones in the middle of your gradient as the browser interpolates between opposite colors.

Open your globals.css right now. Search for #. If you find more than 5 hardcoded hex values scattered outside of your root variables, delete them. Move everything into an 11-step scale, map your UI to the 60-30-10 rule, and strictly enforce the 4.5:1 contrast ratio on every button.

Y
Yann Lephay@YannBuilds

Vibe coder & Indie Hacker. Building tools to help devs ship faster. Creator of OneMinuteBranding.

Ready to create your brand?

Generate a complete brand system with Tailwind config in 60 seconds.

Generate your brand

Related articles

The Developer's Brand Kit Checklist: 23 Files You Need Before Launch

Before you launch, make sure you have these 23 files. From tailwind.config.ts to OG images to CLAUDE.md. Copy this checklist.

The Complete Guide to Favicons, OG Images, and App Icons in 2026

17 different icon sizes, OG images, Apple touch icons. Here's every size you need, where each one goes, and how to generate them all from one source.

Font Pairing for Developers: 7 Combinations That Always Work

Stop spending 2 hours on Google Fonts. These 7 font pairs work for any SaaS product. With next/font code snippets.

Explore more

Branding by RoleBranding by IndustryUse CasesFeaturesIntegrationsGlossaryFree Tools
BlogAboutTermsPrivacy