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-500doesn't work on dark backgrounds - AI generates components using
bg-blue-500in 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, linkssecondary: Secondary actions, accents, highlights
Naming variations:
primary-hover,primary-active,primary-disabledprimary-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 actionsdanger(orerror): Errors, destructive actions, validation failureswarning: Warnings, cautionary messagesinfo: 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 texttext-secondary: Subheadings, descriptions, metadatatext-tertiary: Captions, placeholders, hintstext-disabled: Disabled buttons, inactive statestext-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 canvassurface-secondary: Card backgrounds, sectionssurface-tertiary: Nested cards, input backgroundssurface-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 separatorsborder-strong: Emphasized bordersborder-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-disabledsurface-primary,surface-elevated,surface-overlayborder-default,border-subtle,border-focus
Pattern 2: State-Based
{category}-{state}
Examples:
button-hover,button-active,button-disabledlink-hover,link-visitedinput-focus,input-error
Pattern 3: Component-Specific
component.{component}.{property}
Examples:
component.button.primary.backgroundcomponent.card.bordercomponent.badge.success.text
Recommended Structure
{
"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-whitemight not be your card background tokentext-gray-600might not be your secondary text colorbg-red-500might 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:
- Define base palette (literal names:
blue-500,gray-900) - Add semantic aliases (
primary,text-primary,surface-primary) - Use semantic names in components
- Never hardcode hex values again
Your future self (and AI tools) will thank you.
Want automatic semantic color generation? Try FramingUI.