Skip to main content
  • Created:
  • Updated:
  • Author:
    Takeshi Takatsudo

Three-Tier Color Strategy

The Problem

When building a website or web app, it's tempting to use color values directly in component CSS — picking a hex code for a button, a different shade for a sidebar, and yet another for hover states. This works initially but creates two serious problems:

  1. Changing the brand color requires hunting through every component — there's no single place to update
  2. No semantic meaning — seeing #89b4fa in a button tells you nothing about why that color was chosen or what role it plays

A common first improvement is defining a palette of CSS custom properties (--color-blue-500, --color-red-400). But even with a palette, components end up tightly coupled to specific palette colors. If the design system decides that "primary interactive elements" should shift from blue to indigo, you're back to searching every component.

The Solution

Organize colors into three tiers, each with a clear purpose:

TierNamePurposeExample
1PaletteRaw color values — the full set of available colors--palette-blue-500
2ThemeSemantic roles — what each color means in the design--theme-fg, --theme-accent
3ComponentScoped overrides — colors specific to one component--button-shadow, --card-highlight

The key insight: each tier only references the tier above it. Components use theme tokens. Theme tokens point to palette colors. Palette holds the actual values.

Code Examples

Tier 1: The Palette

The palette is the raw material — every color available in the system. These are not used directly in components. Think of them as paint tubes: you have them ready, but you don't squeeze them onto the canvas without a plan.

Tier 1 — Palette: Raw Color Values

Tier 2: The Theme

Theme tokens give semantic meaning to palette colors. Instead of "blue-500", components see "accent color" or "foreground color". This is the layer that makes redesigns painless — change --theme-accent from blue to indigo in one place, and every component updates.

Tier 2 — Theme: Semantic Mapping from Palette

Tier 3: Component-Scoped Colors

Sometimes a component needs colors that don't fit into the global theme — a shadow, a product variant color, or a subtle gradient stop. These are Tier 3 variables: narrowly scoped, defined on the component itself, and referencing either theme or palette tokens.

Tier 3 — Component: Scoped Color Overrides

All Three Tiers Working Together

Here's a complete example showing how the three tiers connect. Notice how easy it is to trace any color back to its source: component uses theme, theme points to palette.

All Three Tiers — Full Example with CSS Custom Properties

The Power of Tier 2: Swapping Themes

The biggest advantage of the three-tier system is visible when you change the theme. By remapping Tier 2 pointers, every component updates without touching component CSS.

Same Components, Different Themes — Only Tier 2 Changes

Complete CSS Code Structure

Here's how a real project would organize the three tiers across files:

/* ===== tokens/palette.css — Tier 1 ===== */
:root {
--palette-blue-100: oklch(92% 0.06 250);
--palette-blue-300: oklch(74% 0.14 250);
--palette-blue-500: oklch(58% 0.2 250);
--palette-blue-700: oklch(42% 0.16 250);
--palette-blue-900: oklch(28% 0.1 250);

--palette-red-500: oklch(58% 0.22 25);
--palette-green-500: oklch(58% 0.18 150);
--palette-amber-500: oklch(62% 0.18 65);

--palette-gray-50: oklch(98% 0.003 264);
--palette-gray-100: oklch(96% 0.005 264);
--palette-gray-300: oklch(82% 0.01 264);
--palette-gray-500: oklch(58% 0.01 264);
--palette-gray-700: oklch(40% 0.015 264);
--palette-gray-900: oklch(22% 0.015 264);
}

/* ===== tokens/theme.css — Tier 2 ===== */
:root {
/* Layout */
--theme-fg: var(--palette-gray-900);
--theme-bg: var(--palette-gray-50);
--theme-surface: oklch(100% 0 0);
--theme-border: var(--palette-gray-300);
--theme-muted: var(--palette-gray-500);

/* Interactive */
--theme-accent: var(--palette-blue-500);
--theme-accent-hover: var(--palette-blue-700);
--theme-accent-subtle: var(--palette-blue-100);
--theme-accent-fg: oklch(98% 0.01 250);

/* Feedback */
--theme-error: var(--palette-red-500);
--theme-success: var(--palette-green-500);
--theme-warning: var(--palette-amber-500);
}

/* ===== components/button.css — Tier 3 ===== */
.btn {
/* Component-specific color derived from theme.
Relative color syntax (oklch(from ...)) — Baseline 2024.
For wider support, use a hardcoded fallback. */
--btn-shadow: oklch(from var(--theme-accent) l c h / 0.3);

background: var(--theme-accent);
color: var(--theme-accent-fg);
box-shadow: 0 2px 8px var(--btn-shadow);
}
.btn:hover {
background: var(--theme-accent-hover);
}

/* ===== components/product-card.css — Tier 3 ===== */
.product-card {
/* Colors unique to this component, not in theme */
--card-variant: var(--palette-blue-500);
--card-glow: oklch(from var(--card-variant) l c h / 0.15);
}
.product-card--sunset {
--card-variant: oklch(62% 0.2 50);
}

Tailwind CSS: Three Tiers with Custom Theme Config

The three-tier strategy maps naturally to Tailwind's configuration. Palette colors go in theme.colors, theme tokens become CSS custom properties that Tailwind classes reference.

Tailwind: Three-Tier Color System

Tailwind: Theme Switching with Tier 2

The same Tailwind markup works with completely different color schemes — just change the CSS variables.

Tailwind: Same Markup, Three Different Themes

Dark Mode: Just Another Tier 2 Mapping

Dark mode fits naturally into the three-tier system. The palette (Tier 1) stays the same — you already have all the colors. Dark mode is simply an alternative Tier 2 mapping that picks different palette values for the same semantic tokens. The CSS light-dark() function makes this especially clean by co-locating both values in a single declaration.

For a deep dive into dark mode techniques, see Dark Mode Strategies.

Dark Mode via Tier 2 with light-dark()

With light-dark(), the same Tier 2 mapping can be expressed without separate selectors:

/* Tier 2 with light-dark() — both modes in a single declaration */
:root {
color-scheme: light dark;

--theme-bg: light-dark(var(--palette-gray-50), oklch(15% 0.01 264));
--theme-surface: light-dark(oklch(100% 0 0), oklch(20% 0.015 264));
--theme-fg: light-dark(var(--palette-gray-900), oklch(92% 0.005 264));
--theme-muted: light-dark(var(--palette-gray-500), oklch(60% 0.01 264));
--theme-border: light-dark(var(--palette-gray-300), oklch(30% 0.015 264));
--theme-accent: light-dark(var(--palette-blue-500), oklch(70% 0.17 250));
--theme-accent-fg: light-dark(oklch(98% 0.01 250), oklch(15% 0.01 250));
}

Tier 1 and Tier 3 stay exactly the same — only Tier 2 changes. Components never know whether they're in light or dark mode.

Common AI Mistakes

  • Skipping Tier 2 — using palette colors directly in components (color: var(--palette-blue-500)) defeats the purpose; when the brand changes, every component must be updated
  • Too many Tier 3 variables — if a component defines 10+ local color variables, it's likely reinventing the theme layer; promote those to Tier 2
  • Not separating palette from theme — defining --primary: oklch(58% 0.2 250) mixes raw values with semantic meaning, making it impossible to swap palettes
  • Inconsistent naming — mixing --color-primary, --brand-blue, and --accent at the same level creates confusion; use consistent prefixes per tier (--palette-*, --theme-*)
  • Making Tier 1 too small — a palette with only 5 colors forces components to invent their own raw values (Tier 3 colors with hardcoded values), breaking the system
  • Hardcoding colors in Tailwind utilities — using bg-blue-500 instead of bg-theme-accent bypasses the theme layer entirely

When to Use

  • Any project with more than a few components — the overhead of three tiers pays off as soon as you need consistency
  • Multi-theme or white-label products — Tier 2 makes theme switching trivial
  • Design system or component library — components should reference theme tokens, not raw colors
  • Dark mode — dark mode is just another Tier 2 mapping (see Dark Mode Strategies)
  • Gradual adoption — you can start with Tier 1 + 2 and add Tier 3 as components need scoped colors

When three tiers is overkill

  • Single-page sites with one brand color and no theme variations
  • Quick prototypes where speed matters more than maintainability
  • Static sites with minimal interactive elements

References