Using color roles to create consistent design systems
According to Two Hard Things by Martin Fowler, naming things is one of the two hard things in computer science. I think naming colors is no exception.
A default Tailwind configuration gives you access to a wide range of colors, like gray-100
, gray-200
, gray-300
, etc. While these names tell you something about what the color looks like, they don't tell you anything about what the color is used for.
Look at these components for example:
const Button = () => (
<button className="bg-gray-100 text-gray-900 border-gray-200">
Click me
</button>
);
const Typography = () => (
<p className="text-slate-900">
Hello, world!
</p>
);
Both have very similar colors, but they're not the same. You probably want your foreground text to be the same between your components. But in this example, Button
uses colors from the gray palette, while Typography
uses colors from the slate palette. This is a very simple example, you can imagine how this can get out of hand quickly with hover states, focus states, etc.
Using a color pallette directly like this is makes it hard to maintain a consistent design system. Not only because you easily create inconsistencies between components, but also because you need to decide and remember which color to use when working on components. Let alone refactoring your design system when you want to change a color.
To solve that problem, I've often resorted to using colors like primary
, secondary
, tertiary
, etc. This makes deciding which color to use simpler, but it introduces a new problem. For example, the primary
color might mean different things depending on where it's used. A primary background color will be different from a primary text color.
const Button = () => (
<button className="bg-primary text-primary">
Invisible text
</button>
);
Another option is to define colors per component. This is a good approach if you want to have full control. But it can still lead to inconsistencies. In essence, you're maintaining a color palette per component, with the benefit is that you have a clear overview of which colors are used where.
const Button = () => (
<button className="bg-button text-button border-button hover:bg-button-hover focus:bg-button-focus">
Click me
</button>
);
Colors roles are a middleground between using color pallettes directly and defining colors per component. They're a way to group colors together based on their role in your design system. My approach is heavily inspired by Shopify's color roles.
A color role is composed of a collection of tokens that represent different parts of the UI. Each color role follows the same logic for all tokens, but not all tokens are defined for each color role.
Color roles like
default
will have all tokens defined, because the default color role offers the baseline color for all elements in the Shopify admin. Roles likecritical
orinformation
will only have a selection of tokens defined, as these roles are usually applied to specific, smaller and more specialized components like badges or banners.
At the time of writing, the following image describes the color roles for the light theme on this website excluding states.
I use the default
and brand
color roles. The default color role is used for most of the UI, while the brand color role is used for elements that need to stand out, like links and some buttons. But you can imagine having more color roles like critical
, information
, success
for things like alerts, badges, and banners.
Within each role, we have relationships between colors. For example, the default color role has a surface
, fill
, border
, and text
color. These relationships define on what elements
the color should be used.
The image below shows how the colors are applied to a more complicated UI within the same design system.
background
is the background color for very large surface areas, like the entire page.surface
is the background color for elements with the highest level of prominence, like a card or a banner.fill
is the background color for elements with small surface areas, like a button or a badge.border
is primarily used enhance visual separation between elements.text
is the color for text on top of asurface
,background
orfill
.
const Button = () => (
<button className="bg-fill-brand text-text-brand-onFill border-border-brand">
Click me
</button>
);
const Typography = () => (
<p className="text-text-default">
Hello, world!
</p>
);
Let's dissect text-text-brand-onFill
. Here the first text-
is a Tailwind modifier to apply a text color. The second text-
is the relationship between the color and the element, we're applying a text color. The brand
is the color role. The onFill
is a special state to apply the color on top of a fill
color. We could also have used default
or secondary
here.
When we look at a tailwind configuration, we can see how the color roles are defined.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [],
theme: {
extend: {
colors: {
// The relationship between the color and the element
text: {
// The color role
default: {
// The color state
DEFAULT: "var(--text-primary)",
hover: "var(--text-primary-hover)",
active: "var(--text-primary-active)",
onFill: "var(--text-primary-on-fill)",
},
// The brand color role
brand: {
DEFAULT: "var(--text-brand)",
hover: "var(--text-brand-hover)",
active: "var(--text-brand-active)",
onFill: "var(--text-brand-on-fill)",
},
},
}
},
},
}
And these styles can be used in components like this:
<button class="box-border flex flex-shrink-0 items-center font-sans font-medium ring-2 ring-transparent focus:outline-none bg-fill-primary text-text-primary border-border-primary hover:bg-fill-primary-hover hover:border-border-primary-hover active:bg-fill-primary-active focus-visible:ring-border-primary-selected border border-b-2 active:border-b h-10 px-3.5 text-sm rounded-full pl-1 pt-1 pb-1 pr-2" />
If you inspect element this page you will find more examples of color roles in action.
The code for this website is open source, and you can find my color role configuration here.
Thanks for reading! If you have any questions or feedback, feel free to reach out.