Color Systems for Product Design: Beyond Just Picking a Palette
Picking colors is the easy part. You open a palette generator, find five or six hues that feel right, and move on. Two months later you have 47 slightly different blues scattered across your codebase, your dark mode looks like a bruise, and half your text fails WCAG contrast checks.
A color system is not a palette. It is the architecture that sits between your raw color values and the components that use them. Done right, it makes dark mode trivial, accessibility auditable, and brand consistency automatic. Done wrong — or not done at all — every new component becomes a judgment call about which shade of gray to use.
We have built color systems for every product in our portfolio, from the MindHyv business platform to the Threshline website itself. Here is how we approach it.
Start with a base palette, not semantic colors
The first layer of any color system is raw color values. These are your building blocks — numbered scales of each hue, plus a neutral gray ramp. They have no meaning attached to them. They are just colors.
:root {
/* Neutrals */
--palette-gray-50: #fafafa;
--palette-gray-100: #f4f4f5;
--palette-gray-200: #e4e4e7;
--palette-gray-300: #d4d4d8;
--palette-gray-400: #a1a1aa;
--palette-gray-500: #71717a;
--palette-gray-600: #52525b;
--palette-gray-700: #3f3f46;
--palette-gray-800: #27272a;
--palette-gray-900: #18181b;
--palette-gray-950: #09090b;
/* Brand — lime green, the Threshline accent */
--palette-lime-300: #bef264;
--palette-lime-400: #a3e635;
--palette-lime-500: #84cc16;
--palette-lime-600: #65a30d;
--palette-lime-700: #4d7c0f;
/* Functional hues */
--palette-red-400: #f87171;
--palette-red-500: #ef4444;
--palette-blue-400: #60a5fa;
--palette-blue-500: #3b82f6;
--palette-green-400: #4ade80;
--palette-green-500: #22c55e;
--palette-amber-400: #fbbf24;
--palette-amber-500: #f59e0b;
}
A few rules we follow for the base palette:
Use a consistent scale. We stick to the 50-950 numbering that Tailwind popularized. The scale is familiar to most developers, which reduces decision fatigue.
Include enough neutral stops. Nine or ten shades of gray is not overkill. You will use every single one for backgrounds, borders, text, dividers, and hover states. Trying to get by with five grays leads to components that look flat or have insufficient visual hierarchy.
Keep hue count low. Most products need a brand color, a neutral ramp, and three or four functional colors (error, success, warning, info). That is it. Every extra hue is another set of decisions for dark mode, another contrast ratio to check, another thing to maintain. The Threshline palette has exactly five hues plus neutrals.

Semantic tokens: the layer that matters
The base palette is never used directly in components. Between the palette and your UI sits a semantic layer — tokens that describe intent rather than appearance.
/* Light theme semantic tokens */
:root[data-theme="light"] {
/* Backgrounds */
--color-bg-primary: var(--palette-gray-50);
--color-bg-secondary: #ffffff;
--color-bg-tertiary: var(--palette-gray-100);
--color-bg-elevated: #ffffff;
--color-bg-inverse: var(--palette-gray-900);
/* Text */
--color-text-primary: var(--palette-gray-900);
--color-text-secondary: var(--palette-gray-600);
--color-text-muted: var(--palette-gray-400);
--color-text-inverse: #ffffff;
--color-text-link: var(--palette-blue-500);
/* Borders */
--color-border-default: var(--palette-gray-200);
--color-border-strong: var(--palette-gray-300);
--color-border-focus: var(--palette-lime-500);
/* Brand */
--color-accent: var(--palette-lime-500);
--color-accent-hover: var(--palette-lime-600);
--color-accent-text: var(--palette-gray-900);
/* Status */
--color-danger: var(--palette-red-500);
--color-danger-bg: #fef2f2;
--color-success: var(--palette-green-500);
--color-success-bg: #f0fdf4;
--color-warning: var(--palette-amber-500);
--color-warning-bg: #fffbeb;
}
The naming convention is deliberate: --color-{category}-{variant}. Categories are bg, text, border, accent, and status names. Variants describe the role within that category.
This matters because a component like a card never references --palette-gray-100 directly. It uses --color-bg-tertiary. The card does not know or care what color that resolves to. It only knows it needs the tertiary background level.
/* Components use semantic tokens exclusively */
.card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-default);
color: var(--color-text-primary);
}
.card-subtitle {
color: var(--color-text-secondary);
}
.card:hover {
border-color: var(--color-border-strong);
}
When we built the dashboard for LancerSpace, the freelancer workspace platform, the semantic token layer saved us weeks. The design went through three palette revisions during development. Each time, we updated the base palette values and every component updated automatically. No find-and-replace. No visual regression hunting.
Dark mode is just a token remap
If your semantic layer is solid, dark mode is not a rewrite. It is a second mapping from the same token names to different palette values.
/* Dark theme — same token names, different values */
:root[data-theme="dark"] {
/* Backgrounds */
--color-bg-primary: var(--palette-gray-950);
--color-bg-secondary: var(--palette-gray-900);
--color-bg-tertiary: var(--palette-gray-800);
--color-bg-elevated: var(--palette-gray-800);
--color-bg-inverse: var(--palette-gray-50);
/* Text */
--color-text-primary: var(--palette-gray-50);
--color-text-secondary: var(--palette-gray-400);
--color-text-muted: var(--palette-gray-500);
--color-text-inverse: var(--palette-gray-900);
--color-text-link: var(--palette-blue-400);
/* Borders */
--color-border-default: var(--palette-gray-700);
--color-border-strong: var(--palette-gray-600);
--color-border-focus: var(--palette-lime-400);
/* Brand */
--color-accent: var(--palette-lime-400);
--color-accent-hover: var(--palette-lime-300);
--color-accent-text: var(--palette-gray-900);
/* Status */
--color-danger: var(--palette-red-400);
--color-danger-bg: rgba(239, 68, 68, 0.15);
--color-success: var(--palette-green-400);
--color-success-bg: rgba(34, 197, 94, 0.15);
--color-warning: var(--palette-amber-400);
--color-warning-bg: rgba(245, 158, 11, 0.15);
}
Notice a few things:
Dark mode status colors use the 400 shade, not 500. Lighter variants of functional colors read better on dark backgrounds. The 500 shades that work on white backgrounds often look muddy against dark grays.
Status backgrounds use alpha values in dark mode. Instead of picking a specific dark-tinted background, we use the status color at low opacity. This blends naturally with whatever the parent background is and avoids the problem of dark-mode-specific background colors looking disconnected from their status color.
The brand accent shifts up one stop. Our lime green at --palette-lime-500 works great on light backgrounds but needs to be brighter on dark ones. We move to --palette-lime-400 for dark mode. The text on the accent (--color-accent-text) stays gray-900 in both modes because lime is light enough to always need dark text on top.
For more on our full dark mode implementation approach, including the flash-of-wrong-theme problem and system preference detection, see our post on dark mode with design tokens.

WCAG contrast: the math you cannot skip
Accessibility contrast is not optional, and it is not something you check at the end. It needs to be baked into the palette from the start.
The WCAG 2.2 requirements:
- AA normal text (under 18px / 14px bold): 4.5:1 contrast ratio
- AA large text (18px+ / 14px+ bold): 3:1 contrast ratio
- AAA normal text: 7:1 contrast ratio
- Non-text elements (icons, borders, form controls): 3:1 contrast ratio
Here is where most teams get into trouble: they pick a palette that looks good, then discover their secondary text color fails contrast on their background. Or their brand accent does not have enough contrast against white for a button label.
We check contrast at the palette level before defining semantic tokens. A simple TypeScript utility does the math:
function getLuminance(hex: string): number {
const rgb = hex.match(/\w{2}/g)!.map((c) => {
const val = parseInt(c, 16) / 255;
return val <= 0.03928
? val / 12.92
: Math.pow((val + 0.055) / 1.055, 2.4);
});
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
}
function getContrastRatio(hex1: string, hex2: string): number {
const l1 = getLuminance(hex1);
const l2 = getLuminance(hex2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Check your critical pairings
const pairs = [
{ fg: '#18181b', bg: '#fafafa', label: 'text-primary on bg-primary (light)' },
{ fg: '#52525b', bg: '#fafafa', label: 'text-secondary on bg-primary (light)' },
{ fg: '#fafafa', bg: '#09090b', label: 'text-primary on bg-primary (dark)' },
{ fg: '#a1a1aa', bg: '#09090b', label: 'text-secondary on bg-primary (dark)' },
{ fg: '#18181b', bg: '#84cc16', label: 'accent-text on accent (light)' },
{ fg: '#18181b', bg: '#a3e635', label: 'accent-text on accent (dark)' },
];
pairs.forEach(({ fg, bg, label }) => {
const ratio = getContrastRatio(fg, bg);
const passes = ratio >= 4.5 ? 'AA' : ratio >= 3 ? 'AA-large' : 'FAIL';
console.log(`${ratio.toFixed(2)}:1 [${passes}] — ${label}`);
});
Running this against the Threshline palette:
17.41:1 [AA] — text-primary on bg-primary (light)
7.21:1 [AA] — text-secondary on bg-primary (light)
18.42:1 [AA] — text-primary on bg-primary (dark)
6.32:1 [AA] — text-secondary on bg-primary (dark)
9.53:1 [AA] — accent-text on accent (light)
12.18:1 [AA] — accent-text on accent (dark)
Every critical pairing passes AA. We run this check in CI as part of our build process. If someone adds a new token pairing that breaks contrast, the build fails. This is especially important for status colors — --color-danger on --color-danger-bg must be legible, not just decorative.
Tokens in Tailwind CSS
If you use Tailwind — and we do on every project — you want your semantic tokens available as utility classes, not just CSS custom properties.
// tailwind.config.ts
import type { Config } from 'tailwindcss';
export default {
theme: {
extend: {
colors: {
bg: {
primary: 'var(--color-bg-primary)',
secondary: 'var(--color-bg-secondary)',
tertiary: 'var(--color-bg-tertiary)',
elevated: 'var(--color-bg-elevated)',
inverse: 'var(--color-bg-inverse)',
},
text: {
primary: 'var(--color-text-primary)',
secondary: 'var(--color-text-secondary)',
muted: 'var(--color-text-muted)',
inverse: 'var(--color-text-inverse)',
link: 'var(--color-text-link)',
},
border: {
DEFAULT: 'var(--color-border-default)',
strong: 'var(--color-border-strong)',
focus: 'var(--color-border-focus)',
},
accent: {
DEFAULT: 'var(--color-accent)',
hover: 'var(--color-accent-hover)',
text: 'var(--color-accent-text)',
},
danger: {
DEFAULT: 'var(--color-danger)',
bg: 'var(--color-danger-bg)',
},
success: {
DEFAULT: 'var(--color-success)',
bg: 'var(--color-success-bg)',
},
warning: {
DEFAULT: 'var(--color-warning)',
bg: 'var(--color-warning-bg)',
},
},
},
},
} satisfies Config;
Now your components look like this:
<div class="bg-bg-secondary border border-border rounded-lg p-4">
<h3 class="text-text-primary font-semibold">Card Title</h3>
<p class="text-text-secondary mt-1">Card description text.</p>
<button class="bg-accent text-accent-text hover:bg-accent-hover px-4 py-2 rounded mt-3">
Action
</button>
</div>
No dark: variants scattered everywhere. No conditional classes. The theme switch just changes the CSS custom property values, and every Tailwind utility updates automatically.

Common mistakes we see
Using palette values directly in components. This is the most common problem. The moment someone writes bg-gray-800 instead of bg-bg-secondary, they have bypassed the system. It works until you change the palette or add a new theme.
Too many semantic tokens. If you have --color-bg-card, --color-bg-modal, --color-bg-dropdown, and --color-bg-popover and they all resolve to the same value, you have over-specified. Use --color-bg-elevated for all of them. Add specificity only when the values actually differ between themes.
Forgetting non-text contrast. Your borders, icons, and form controls also need 3:1 contrast against their background. We see this a lot with light gray borders on white backgrounds — decorative-looking but technically invisible to low-vision users.
Not testing both themes simultaneously. Every component should look good in both themes. We review PRs with both themes open side by side. It is the only reliable way to catch dark mode regressions before they ship.
The payoff
A solid color system is an upfront investment that pays dividends on every feature after it. New components inherit the right colors by default. Dark mode comes free. Accessibility is built in rather than bolted on. And when the client wants to tweak the brand color six months in, it is a one-line change instead of a codebase-wide audit.
We built this exact system for projects like Vincelio, Trackelio, and our own site. The pattern is the same every time because it works every time.
If you are building a product and want a color system that scales with you, reach out at [email protected]. We have done this enough times to get it right the first time.