Tutorial

Semantic Color Naming: A Guide for Developers

Stop using 'blue-500' everywhere. Learn semantic color naming for design systems that scale, support dark mode, and make AI code generation actually work.

FramingUI Team13 min read

The Problem

You're building a button component. What color do you use?

Option 1: Hardcoded hex

<button style={{ background: '#3B82F6' }}>Click me</button>

Option 2: Literal color name

<button className="bg-blue-500">Click me</button>

Option 3: Semantic name

<button className="bg-primary-500">Click me</button>

Most developers pick option 1 or 2. Then six months later:

  • The brand color changes from blue to purple
  • Dark mode is added, and blue-500 doesn't work on dark backgrounds
  • AI generates components using bg-blue-500 in random places
  • You have 50+ hardcoded blues scattered across the codebase

The root cause: Non-semantic color naming.

This guide teaches you how to name colors by purpose, not appearance—a skill that makes design systems scalable, themeable, and AI-friendly.

TL;DR

  • Bad naming: blue-500, gray-700, #3B82F6 (describes appearance)
  • Good naming: primary, text-primary, surface-elevated (describes purpose)
  • Semantic naming means colors are named by role (what they do) not value (what they look like)
  • Benefits: Easy theme switching, dark mode support, brand color changes don't break everything
  • AI tools generate better code with semantic names (they understand intent)
  • Use a two-tier system: base palette (literal names) → semantic aliases (role-based names)

Why Literal Color Names Fail

Scenario 1: Brand Color Change

Your startup launches with blue as the primary color:

/* All over your codebase */
.button { background: blue-500; }
.link { color: blue-600; }
.badge { border-color: blue-400; }
.focus-ring { ring-color: blue-500; }

Six months later, your designer says: "We're rebranding to purple."

How many files do you need to update? All of them. And you'll miss some, guaranteed.

With semantic naming:

.button { background: var(--color-primary-500); }
.link { color: var(--color-primary-600); }
.badge { border-color: var(--color-primary-400); }
.focus-ring { ring-color: var(--color-primary-500); }

To rebrand: Change one line in your design tokens:

{
  "color": {
    "primary": {
      "500": { "value": "#8B5CF6" } // Changed from blue to purple
    }
  }
}

Rebuild → entire app updates. No find-and-replace nightmares.

Scenario 2: Dark Mode

You use gray-100 for card backgrounds in light mode. Looks great.

Then you add dark mode. Now gray-100 (a light gray) makes no sense on a dark background.

Literal naming:

<div className="bg-gray-100 dark:bg-gray-800">
  <!-- Every component needs manual dark mode overrides -->
</div>

Semantic naming:

<div className="bg-surface-primary">
  <!-- Color automatically adapts to theme -->
</div>
:root {
  --color-surface-primary: #F9FAFB; /* light mode */
}

[data-theme="dark"] {
  --color-surface-primary: #1F2937; /* dark mode */
}

One class. No dark: prefix needed. Scales to infinite themes (high contrast, colorblind-friendly, etc.).

Scenario 3: AI Code Generation

Prompt: "Create a call-to-action button"

AI with literal colors:

<button className="bg-blue-500 hover:bg-blue-600 text-white">
  Get Started
</button>

AI picked blue-500 because that's common in examples. But is that your brand's CTA color? Maybe. Maybe not.

AI with semantic colors:

AI reads your design tokens via MCP:

{
  "component": {
    "button": {
      "primary": {
        "background": { "value": "color.primary.500" }
      }
    }
  }
}

AI output:

<button className="bg-primary-500 hover:bg-primary-600 text-white">
  Get Started
</button>

AI used the correct color because the token name describes purpose (primary CTA), not appearance (blue).

Semantic vs. Literal Naming: Core Concepts

Literal Naming (Bad)

Names describe what the color looks like:

blue-500, red-600, gray-900, teal-400

Problems:

  • Doesn't tell you where to use it
  • Breaks when color changes (blue → purple? Now the name is wrong)
  • Requires manual mapping to UI roles

Semantic Naming (Good)

Names describe what the color does:

primary, danger, text-primary, surface-elevated, border-subtle

Benefits:

  • Describes purpose (primary CTA, error state, card background)
  • Works with any color value (blue, purple, green—name stays the same)
  • Self-documenting (developers know when to use it)

The Two-Tier Color System

Best practice: Base palette + semantic aliases

┌─────────────────────┐
│   Base Palette      │  ← Literal names (blue-500, gray-700)
│   (Color values)    │     Pure colors with no meaning
└──────────┬──────────┘
           │
           │  Reference (alias)
           │
           ▼
┌─────────────────────┐
│  Semantic Tokens    │  ← Role-based names (primary, danger, text-primary)
│  (Aliases)          │     What the color is FOR
└─────────────────────┘

Example

Base palette (literal):

{
  "color": {
    "blue": {
      "400": { "value": "#60A5FA", "type": "color" },
      "500": { "value": "#3B82F6", "type": "color" },
      "600": { "value": "#2563EB", "type": "color" }
    },
    "red": {
      "500": { "value": "#EF4444", "type": "color" },
      "600": { "value": "#DC2626", "type": "color" }
    },
    "gray": {
      "100": { "value": "#F3F4F6", "type": "color" },
      "900": { "value": "#111827", "type": "color" }
    }
  }
}

Semantic aliases:

{
  "color": {
    "primary": {
      "400": { "value": "{color.blue.400}", "type": "color" },
      "500": { "value": "{color.blue.500}", "type": "color" },
      "600": { "value": "{color.blue.600}", "type": "color" }
    },
    "danger": {
      "500": { "value": "{color.red.500}", "type": "color" },
      "600": { "value": "{color.red.600}", "type": "color" }
    },
    "text": {
      "primary": { "value": "{color.gray.900}", "type": "color" },
      "secondary": { "value": "{color.gray.600}", "type": "color" }
    }
  }
}

In code:

// ❌ Bad: literal name
<button className="bg-blue-500">Submit</button>

// ✅ Good: semantic name
<button className="bg-primary-500">Submit</button>

To rebrand, update the alias:

{
  "color": {
    "primary": {
      "500": { "value": "{color.purple.500}" } // Changed reference
    }
  }
}

All components using primary-500 now show purple. Zero code changes.

Semantic Color Categories

1. Brand Colors (Intentional Actions)

Use for primary CTAs, links, focus states, brand elements.

{
  "color": {
    "primary": {
      "50": { "value": "#EFF6FF" },
      "500": { "value": "#3B82F6" },
      "900": { "value": "#1E3A8A" }
    },
    "secondary": {
      "500": { "value": "#8B5CF6" }
    }
  }
}

When to use:

  • primary: Main CTA buttons, active navigation, links
  • secondary: Secondary actions, accents, highlights

Naming variations:

  • primary-hover, primary-active, primary-disabled
  • primary-text (text on primary background)

2. Feedback Colors (System States)

Use for success, error, warning, info messages.

{
  "color": {
    "success": {
      "50": { "value": "#F0FDF4" },
      "500": { "value": "#22C55E" },
      "700": { "value": "#15803D" }
    },
    "danger": {
      "50": { "value": "#FEF2F2" },
      "500": { "value": "#EF4444" },
      "700": { "value": "#B91C1C" }
    },
    "warning": {
      "500": { "value": "#F59E0B" }
    },
    "info": {
      "500": { "value": "#3B82F6" }
    }
  }
}

When to use:

  • success: Success messages, confirmations, positive actions
  • danger (or error): Errors, destructive actions, validation failures
  • warning: Warnings, cautionary messages
  • info: Informational messages, tips

Common mistake: Using error for both error text AND error backgrounds. Be specific:

{
  "color": {
    "error": {
      "text": { "value": "#B91C1C" },
      "bg": { "value": "#FEF2F2" },
      "border": { "value": "#FCA5A5" }
    }
  }
}

3. Text Colors (Content Hierarchy)

Use for body text, headings, labels, disabled text.

{
  "color": {
    "text": {
      "primary": { "value": "#111827" },
      "secondary": { "value": "#6B7280" },
      "tertiary": { "value": "#9CA3AF" },
      "disabled": { "value": "#D1D5DB" },
      "inverse": { "value": "#FFFFFF" }
    }
  }
}

When to use:

  • text-primary: Main headings, body text
  • text-secondary: Subheadings, descriptions, metadata
  • text-tertiary: Captions, placeholders, hints
  • text-disabled: Disabled buttons, inactive states
  • text-inverse: Text on dark backgrounds

4. Surface Colors (Backgrounds & Layers)

Use for page backgrounds, card backgrounds, modals, elevated surfaces.

{
  "color": {
    "surface": {
      "primary": { "value": "#FFFFFF" },
      "secondary": { "value": "#F9FAFB" },
      "tertiary": { "value": "#F3F4F6" },
      "elevated": { "value": "#FFFFFF" },
      "overlay": { "value": "rgba(0, 0, 0, 0.5)" }
    }
  }
}

When to use:

  • surface-primary: Page background, main canvas
  • surface-secondary: Card backgrounds, sections
  • surface-tertiary: Nested cards, input backgrounds
  • surface-elevated: Modals, dropdowns, tooltips (with shadow)
  • surface-overlay: Modal backdrops, dimmed backgrounds

5. Border Colors (Dividers & Outlines)

Use for borders, dividers, focus rings.

{
  "color": {
    "border": {
      "default": { "value": "#E5E7EB" },
      "subtle": { "value": "#F3F4F6" },
      "strong": { "value": "#D1D5DB" },
      "focus": { "value": "#3B82F6" }
    }
  }
}

When to use:

  • border-default: Standard borders (cards, inputs, dividers)
  • border-subtle: Barely-visible separators
  • border-strong: Emphasized borders
  • border-focus: Focus rings on interactive elements

Dark Mode with Semantic Colors

The Wrong Way

// ❌ Manual dark mode overrides everywhere
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white border-gray-200 dark:border-gray-700">
  <h2 className="text-gray-900 dark:text-white">Title</h2>
  <p className="text-gray-600 dark:text-gray-400">Description</p>
</div>

Problems:

  • 3x the class names
  • Easy to forget dark: overrides
  • Doesn't scale (what about high-contrast mode? Colorblind themes?)

The Right Way

// ✅ Semantic colors adapt automatically
<div className="bg-surface-primary text-text-primary border-border-default">
  <h2 className="text-text-primary">Title</h2>
  <p className="text-text-secondary">Description</p>
</div>

Token definitions:

/* Light mode (default) */
:root {
  --color-surface-primary: #FFFFFF;
  --color-text-primary: #111827;
  --color-text-secondary: #6B7280;
  --color-border-default: #E5E7EB;
}

/* Dark mode */
[data-theme="dark"] {
  --color-surface-primary: #111827;
  --color-text-primary: #F9FAFB;
  --color-text-secondary: #D1D5DB;
  --color-border-default: #374151;
}

Result: Change data-theme attribute → entire app re-themes. Zero component changes.

Common Semantic Naming Mistakes

Mistake 1: Too Many Shades

{
  "color": {
    "primary": {
      "50": "...",
      "100": "...",
      "200": "...",
      "300": "...",
      "400": "...",
      "500": "...",  // Which one is the "main" primary?
      "600": "...",
      "700": "...",
      "800": "...",
      "900": "..."
    }
  }
}

Problem: Developers don't know which shade to use. Is primary-500 for buttons? Or primary-600?

Solution: Define semantic aliases for each use case:

{
  "component": {
    "button": {
      "primary": {
        "background": { "value": "{color.primary.500}" },
        "background-hover": { "value": "{color.primary.600}" },
        "text": { "value": "{color.white}" }
      }
    }
  }
}

Now developers use button-primary-background, not primary-500.

Mistake 2: Mixing Literal and Semantic Names

{
  "color": {
    "blue-500": "#3B82F6",  // Literal
    "primary": "#3B82F6",   // Semantic
    "cta-button": "#3B82F6" // Overly specific
  }
}

Problem: Inconsistent. Developers won't know which to use.

Solution: Stick to one system (semantic) and use the two-tier approach:

{
  "color": {
    "blue": { "500": "#3B82F6" },        // Base palette (literal)
    "primary": { "value": "{color.blue.500}" }  // Semantic alias
  }
}

Mistake 3: Component-Specific Colors

{
  "color": {
    "button-primary-background": "#3B82F6",
    "button-secondary-background": "#8B5CF6",
    "card-background": "#FFFFFF",
    "modal-background": "#FFFFFF",
    "input-background": "#F9FAFB"
  }
}

Problem: This explodes into hundreds of tokens. Every component gets its own color, even when many use the same value.

Solution: Use layered semantics—base colors + component tokens that reference base colors:

{
  "color": {
    "primary": { "500": "#3B82F6" },
    "surface": { "primary": "#FFFFFF", "secondary": "#F9FAFB" }
  },
  "component": {
    "button": {
      "primary": { "background": { "value": "{color.primary.500}" } }
    },
    "card": {
      "background": { "value": "{color.surface.primary}" }
    },
    "modal": {
      "background": { "value": "{color.surface.primary}" }
    }
  }
}

Now card and modal share the same base color, but you can override individually if needed.

Naming Conventions

Pattern 1: Role-Based

{category}-{role}[-{variant}]

Examples:

  • text-primary, text-secondary, text-disabled
  • surface-primary, surface-elevated, surface-overlay
  • border-default, border-subtle, border-focus

Pattern 2: State-Based

{category}-{state}

Examples:

  • button-hover, button-active, button-disabled
  • link-hover, link-visited
  • input-focus, input-error

Pattern 3: Component-Specific

component.{component}.{property}

Examples:

  • component.button.primary.background
  • component.card.border
  • component.badge.success.text
{
  "color": {
    // Base palette (literal names)
    "blue": { "500": "#3B82F6" },
    "gray": { "100": "#F3F4F6", "900": "#111827" },

    // Semantic aliases (role-based)
    "primary": { "500": "{color.blue.500}" },
    "text": {
      "primary": "{color.gray.900}",
      "secondary": "{color.gray.600}"
    },
    "surface": {
      "primary": "{color.white}",
      "secondary": "{color.gray.100}"
    },

    // Component-specific (references semantic)
    "component": {
      "button": {
        "primary": {
          "background": "{color.primary.500}",
          "text": "{color.white}"
        }
      }
    }
  }
}

AI Benefits of Semantic Naming

AI Without Semantic Colors

Prompt: "Create a card with a title, description, and a danger button"

AI output:

<div className="bg-white p-4 rounded border border-gray-200">
  <h3 className="text-xl font-bold text-gray-900">Title</h3>
  <p className="text-gray-600 mt-2">Description</p>
  <button className="mt-4 bg-red-500 text-white px-4 py-2 rounded">
    Delete
  </button>
</div>

Problems:

  • bg-white might not be your card background token
  • text-gray-600 might not be your secondary text color
  • bg-red-500 might not be your danger color

You have to manually fix 4+ color classes.

AI With Semantic Colors

AI reads your design tokens:

{
  "color": {
    "surface": { "secondary": "#FFFFFF" },
    "text": { "primary": "#111827", "secondary": "#6B7280" },
    "border": { "default": "#E5E7EB" },
    "danger": { "500": "#EF4444" }
  }
}

AI output:

<div className="bg-surface-secondary p-4 rounded border border-border-default">
  <h3 className="text-xl font-bold text-text-primary">Title</h3>
  <p className="text-text-secondary mt-2">Description</p>
  <button className="mt-4 bg-danger-500 text-white px-4 py-2 rounded">
    Delete
  </button>
</div>

Result: Every color class is correct on the first try. AI understood intent from token names.

How to Migrate Existing Codebases

Step 1: Audit Current Colors

Find all hardcoded colors:

# Search for hex codes
rg "#[0-9A-Fa-f]{6}" --type tsx

# Search for Tailwind color classes
rg "bg-(blue|red|gray|green)-\d+" --type tsx

Step 2: Group by Purpose

Categorize colors by role:

  • Primary CTAs → color.primary
  • Error states → color.danger
  • Card backgrounds → color.surface.secondary
  • Body text → color.text.primary

Step 3: Define Semantic Tokens

{
  "color": {
    "primary": { "500": "#3B82F6" },
    "danger": { "500": "#EF4444" },
    "text": { "primary": "#111827" },
    "surface": { "secondary": "#FFFFFF" }
  }
}

Step 4: Replace Gradually

Don't try to update everything at once. Replace component-by-component:

- <button className="bg-blue-500 hover:bg-blue-600">
+ <button className="bg-primary-500 hover:bg-primary-600">

Use codemod tools (e.g., jscodeshift) for bulk replacements.

Step 5: Enforce with Linting

Add ESLint rules to prevent hardcoded colors:

// .eslintrc.js
module.exports = {
  rules: {
    "no-hex-colors": "error", // Custom rule
    "no-literal-colors": "error"
  }
}

Real-World Example: GitHub's Color System

GitHub uses a sophisticated semantic color system. Here's a simplified version:

{
  "color": {
    "text": {
      "primary": "#1F2328",
      "secondary": "#656D76",
      "link": "#0969DA",
      "danger": "#CF222E"
    },
    "bg": {
      "default": "#FFFFFF",
      "subtle": "#F6F8FA",
      "emphasis": "#1F2328"
    },
    "border": {
      "default": "#D0D7DE",
      "muted": "#D0D7DE"
    },
    "btn": {
      "primary": {
        "bg": "#1F883D",
        "text": "#FFFFFF",
        "hover": "#1A7F37"
      },
      "danger": {
        "bg": "#CF222E",
        "text": "#FFFFFF",
        "hover": "#A40E26"
      }
    }
  }
}

Key takeaways:

  • Role-based names (text-primary, bg-default)
  • Component-specific overrides (btn.primary.bg)
  • Dark mode support (GitHub swaps these values for dark theme)

Tools & Resources

Token Validators

  • Style Dictionary: Transform design tokens into platform-specific code
  • FramingUI: Auto-generate CSS, Tailwind, TypeScript types, and AI integration from tokens

Naming Generators

  • Token Namer (coming soon): Analyze your colors, suggest semantic names
  • Figma Tokens Studio: Plugin for managing semantic tokens in Figma

Linters

  • ESLint rules: Enforce semantic color usage, ban hardcoded hex values

Conclusion

Semantic color naming is one of the highest-leverage improvements you can make to your design system.

Benefits:

  • ✅ Rebrand in minutes (change one value, not hundreds)
  • ✅ Dark mode with zero manual overrides
  • ✅ AI generates on-brand code automatically
  • ✅ Self-documenting (names explain purpose)
  • ✅ Scales to any theme (high-contrast, colorblind-friendly, etc.)

The key: Name colors by what they do, not what they look like.

Start simple:

  1. Define base palette (literal names: blue-500, gray-900)
  2. Add semantic aliases (primary, text-primary, surface-primary)
  3. Use semantic names in components
  4. Never hardcode hex values again

Your future self (and AI tools) will thank you.

Want automatic semantic color generation? Try FramingUI.

Ready to build with FramingUI?

Build consistent UI with AI-ready design tokens. No more hallucinated colors or spacing.

Try FramingUI
Share

Related Posts