Here’s something most website builders don’t think about until it bites them: the way you manage color on a project has a massive impact on how easy (or miserable) that project is to maintain.
Not just at launch. Six months later, when a client wants to tweak their brand color. A year later, when you’re adding a new section and can’t remember what shade of blue you used on the buttons. Two years later, when someone else entirely has to touch the project and has no idea what’s going on.
Color management is one of those things that feels fine when you’re flying through a build, and only reveals its problems when something needs to change.
This post is about a two-layer approach to color (using primitives and semantics together) that solves problems you might not even know you have yet. But to understand why it works, we need to start with why the simpler approaches fall short.
Prefer watching? This post is available in video form!
The problem: 16 million colors is too many choices
Did you know there are over 16 million possible color values in HEX alone?
That’s not a fun fact. That’s the problem. Every time you open a color picker, that’s technically how many options you’re choosing from. It’s no wonder color decisions feel hard. Crayola doesn’t make a 16 million piece set because you wouldn’t know where to start. But that’s essentially what our tools hand us.
So the natural response is to build a palette. A curated, intentional set of colors for the project, instead of reaching into the void every time you need to pick something.
That’s the right instinct. The question is how you build and manage that palette. And this is where most people, without realizing it, take a wrong turn.
A palette is more than you think
Before we get into the approaches, it’s worth acknowledging that a project palette isn’t just your brand colors. It’s a lot more than that.
For any real website, you’re going to need:
- Neutral colors for backgrounds, borders, and body text
- Brand colors — your primary and secondary
- Utility colors for success states, warnings, and errors
- Shades of all of the above for hover states, disabled states, subtle accents, and more
That adds up to a lot of colors. And the more colors you have, the harder they are to manage consistently. Especially when you’re working in a visual builder and there’s no automated enforcement keeping things in line.
Approach 1: Primitives
A primitive palette is the most common first step designers take when they want to get more organized. Instead of grabbing random HEX values whenever you need a color, you define a structured set of named colors upfront.
Tailwind CSS is a great real-world example. Their palette has 22 color families — blue, red, green, orange, gray, and so on — each with 11 shades numbered from 100 (lightest) to 950 (darkest). Names like blue-500, gray-200, red-900.
That takes you from 16 million possible values down to 242 named options. A massive improvement. And for a typical project, you’d probably only use 6-8 of those color families, which narrows it further.
In CSS using custom properties, a primitive palette looks like this:
:root {
/* Blue */
--blue-100: #dbeafe;
--blue-500: #3b82f6;
--blue-600: #2563eb;
--blue-700: #1d4ed8;
--blue-800: #1e40af;
--blue-900: #1e3a8a;
/* Gray */
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-500: #6b7280;
--gray-800: #1f2937;
--gray-900: #111827;
}
Clean, organized, consistent. Every color value lives in one place.
Where primitives fall short
Here is the problem: a primitive palette tells you what colors exist, but it tells you nothing about where they are used.
Six months into a project, you need to update the button color. You open your builder and stare at your palette. Which blue was it? You used --blue-600 on most buttons, but you also have a secondary button style. Was that --blue-400? Or --blue-500? And what about the links? Were those --blue-600 too, or a different shade?
Primitives are better than raw HEX values. But the names don’t carry any meaning about how they’re applied. You still have to remember (or reverse-engineer) what goes where.
“Did I put
--blue-900on the buttons? Or was it--blue-700?”
If you’ve ever caught yourself asking that question, you’ve hit the ceiling of a primitives-only approach.
Approach 2: Semantics
Semantic color naming takes a completely different approach. Instead of naming colors by their visual properties (blue-600), you name them by their purpose in the design.
Instead of --blue-600, you have --button-background. Instead of --gray-200, you have --border-subtle. Instead of --gray-900, you have --heading.
In practice, it looks like this:
:root {
--button-background: #2563eb;
--button-background-hover: #1d4ed8;
--button-text: #f3f4f6;
--link-color: #2563eb;
--link-color-hover: #1e40af;
--text: #1f2937;
--text-muted: #6b7280;
--heading: #111827;
--background: #f3f4f6;
--border: #d1d5db;
--cta-background: #2563eb;
--cta-text: #f3f4f6;
--color-success: #16a34a;
--color-warning: #f97316;
--color-error: #dc2626;
}
Now when you need to update the button color, you go straight to –button-background. No guessing. No hunting.
The name tells you exactly what it is.
Where semantics fall short
Here’s the fatal flaw: in a real project, many of your semantic colors will share the same value.
Your button background, your links, your CTA backgrounds, and your icon color might all be the exact same blue. They should be, for consistency. So in a semantics-only palette, you’d have:
--button-background: #2563eb;
--link-color: #2563eb;
--cta-background: #2563eb;
--icon-color: #2563eb;
Four separate variables. All set to the same HEX value. All disconnected from each other.
Now imagine your client tells you that blue is a touch too bright and they want to shift it slightly. You have to find every semantic variable using that HEX value and update them one by one. Miss one (and it’s easy to miss one) and your colors are now out of sync. Buttons are one shade of blue. Links are a slightly different shade. CTAs are the original. The project looks subtly broken in a way that’s hard to pinpoint.
Nothing ties those repeated values together. They’re just scattered across your stylesheet, independent and invisible to each other.
Why neither approach is enough on its own
Let’s take stock:
- Primitives give you an organized, consistent palette… but the names mean nothing about how the colors are used, so you’re constantly guessing what goes where.
- Semantics give you meaningful names that make your work intuitive… but repeated HEX values have nothing connecting them, so global changes become a tedious, error-prone hunt.
Both approaches feel like they should work. And they do — up to a point. Primitives work fine when your project is small and you remember everything. Semantics work fine when nothing ever needs to change.
But projects grow. Colors change. Clients call.
And when that happens, both approaches reveal the same fundamental limitation: they’re trying to solve two different problems with one layer, and one layer can’t do both jobs well.
The two-layer approach: primitives + semantics together
Primitives and semantics aren’t competing approaches. They’re complementary layers. When you use them together, each one does the job it’s actually good at.
Primitives define your palette: every color and shade the project is allowed to use, each pointing directly to a HEX value. This is your single source of truth for color values.
Semantics define how your palette is applied: human-friendly names for every element in your project, each pointing not to a HEX value, but to one of your primitives.
/* PRIMITIVES — your palette */
:root {
--blue-600: #2563eb;
--blue-700: #1d4ed8;
--blue-800: #1e40af;
--orange-600: #ea580c;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-500: #6b7280;
--gray-800: #1f2937;
--gray-900: #111827;
--green-600: #16a34a;
--red-600: #dc2626;
}
/* SEMANTICS — how the palette is applied */
:root {
--button-background: var(--blue-600);
--button-background-hover: var(--blue-700);
--button-text: var(--gray-100);
--link-color: var(--blue-600);
--link-color-hover: var(--blue-800);
--text: var(--gray-800);
--text-muted: var(--gray-500);
--heading: var(--gray-900);
--background: var(--gray-100);
--background-subtle: var(--gray-200);
--border: var(--gray-300);
--cta-background: var(--blue-600);
--cta-text: var(--gray-100);
--color-success: var(--green-600);
--color-warning: var(--orange-500);
--color-error: var(--red-600);
}
Notice what’s happening: --button-background, --link-color, and --cta-background all point to --blue-600.
They share the same primitive — but they’re each their own independent semantic.
How this solves the primitives problem
With semantics on top of your primitives, you never need to remember whether buttons use --blue-600 or --blue-700. You just use --button-background and let the system handle the rest. The name tells you exactly what it is and where it belongs.
How this solves the semantics problem
With primitives as the foundation, a global color change becomes trivial. If your blue needs to shift, you update --blue-600 in one place, and --button-background, --link-color, and --cta-background all update automatically, because they’re all pointing to the same primitive.
The bonus: independent control
Because each semantic is its own variable, you can change individual elements independently without touching anything else.
Your client calls. Buttons should be orange.
One change:
--button-background: var(--orange-600);
That’s it. Only buttons change.
Links stay blue. CTAs stay blue. Everything else remains exactly where it was, still drawing from your defined primitives, still fully within your palette.
What you actually get
By using primitives and semantics together, you get:
- A defined, intentional palette. Primitives limit your project to a specific set of colors. Every color value lives in one place and nowhere else.
- Names that actually mean something. Semantics make your work intuitive. You always know what you’re looking for, without reverse-engineering what a number means.
- Global control and element-level flexibility at the same time. Update a primitive and everything using it follows. Update a semantic and only that element moves. You get both, without compromise.
Where you’ve probably been
Chances are you’ve experimented with both approaches. Maybe you’ve even unintentionally mixed the two. And if you haven’t seen how they benefit from working together, you’ve probably run into every limitation we talked about… and it’s frustrating, because both approaches feel like they should work on their own.
It took me a few attempts before this finally clicked. I’ve got projects out there using one, the other, or a messy combination of both. But once you separate the two layers and let them work together, everything gets more consistent, less messy, and a whole lot easier to manage.

