This post is the first in a three-part series about how our team at You Need a Budget (YNAB) thinks about color in our design system. We'll go over the principles of how our system works, what inspired us to build it, and why we're excited about it.
Over the past few months, the team at YNAB revamped how we approach colors in our design system.
Colors are deceptively simple. They are a foundational element of interfaces but all-too-frequently end up becoming a sprawling mess of inline definitions, different naming conventions across different platforms, confusion around when to use what color, and issues with updates when things change. And dark mode support, which is becoming the norm, introduces yet another dimension of complexity to colors.
Our new system addresses these challenges head-on by using a combination of semantic naming and an additional “layer of abstraction” (more on what exactly this means later) in both our designs and in code.
This series is all about what we might call the “form” of colors in our design system: How we structure the system and how it works for designers and developers.
This series won’t cover the “content” of colors in our design system: Things like how we selected the actual color values we use, how we ensure adequate contrast, our color decisions in designing for dark mode, etc.
Okay, let’s get into it!
If you’re a designer or a developer, chances are you’ve seen the words “semantic” and “color” in the same sentence before. It usually refers to a way of naming colors based on how they are used as opposed to their hue. This is one of many theories on the best way to name color.
With a semantic name, you might name a color “destructive” or “negative” — something that connotes what the color means in your interface, as opposed to something like “red” (or even something more fun, like “tomato”).
Naming colors semantically has two benefits:
- It helps designers and developers decide what color to use. Instead of needing to memorize or check documentation to decide what color to make a “delete” button, you simply grab the color that relates to destruction.
- It makes your color system more efficient and flexible. If you decide you want to update your primary color to be purple instead of blue, you simply update the value for the primary color (assuming you’re using color styles in a tool like Figma or Sketch and color variables in code). If the colors were named based on hue, you’d need to go change every single place the color is used from “blue” to “purple.”
Our new approach to color introduces an additional layer of abstraction to address some of the problems that are left unsolved by simply naming each of your colors semantically.
A color palette is, of course, an integral piece of any design system. The palette represents the universe of possibility for color in an interface.
A palette is a great start for building visually consistent apps — a defined set of colors mitigates the chance that a rogue hex value will sneak its way in.
Here’s what our palette looks like at YNAB:
Each of the colors has a semantic name (e.g.
accent, etc.) as well as a number that corresponds to its lightness (e.g.
900 for the darkest variants;
100 for the lightest variants). But if we stopped here, a lot would still remain up for interpretation as far as when to use what color. And not in a good “this leaves space for creativity!” way; more in a “What primary color variant do we use for buttons again?” way.
Our semantic colors go further than simply naming colors based on the general realm of their usage. They are a second layer of abstraction that sits on top of the base palette.
Our color system consists of two layers:
- The base palette, which defines every possible color value in our app. The semantic palette, which defines colors based on how they are used. Each and every semantic color points to a color from the base palette.
- For example, we have a semantic color for our primary action color and another one for the background of headers. Each of those semantic colors references a color from the base palette.
Now, you may be thinking, “This seems like a bunch of extra work for something that we get for free with symbols/components.” And in the case of something like a button or a header, that may very well be true — when you design the primary button component, you pick a color from the palette and then others simply use that component in their designs without needing to think about the specific color.
But as things get more complex, the benefits of this two-layer system become clear.
YNAB is budgeting software, which means that we frequently use color as a visual signal for positive and negative account balances (we never rely only on color, but color and accessibility would be a whole separate post).
For example, the budget view features “pills” that display the amount remaining in a given category of a user’s budget. There is also a bar at the top of the screen that displays overall budget status.
To keep things consistent, we want both of these elements to use the exact same color. That way, users can start to learn that when they see this particular red color, it always means the same thing — a negative balance!
In our semantic color system, we name this color
statusNegative and set it to reference a color from our base palette. So if we want to update this color, we simply point it at a different palette color and the change propagates to all elements that are meant to convey a negative status. And if we update a base palette color, the change will ripple through all of the semantic colors that reference that color (and then all of the elements that use those semantic colors).
This is what we mean by introducing an additional layer of abstraction. Semantic colors act as an intermediary level of specificity, between the raw value of colors in the base palette and the usage of those colors in specific components.
And here’s the best part: Semantic colors make it easy for designers to create new elements that are visually consistent with the rest of the app. If we wanted to create a graph on a dashboard that is in a negative state, we’d simply drop in
statusNegative instead of arbitrarily searching through our red hues.
Beyond visual consistency, a major benefit of this system is how easily it supports dark mode. In fact, adapting our apps to dark mode is what inspired this approach to color in the first place.
Let’s consider the color we use for primary elements —
primaryAction. This color references
primary600 from the base palette.
Great. But what about dark mode?
Well, all we have to do is create an additional mapping for
For dark mode, we map it to
primary500, a lighter blue, because we want primary elements to “pop” more on dark backgrounds (like accessibility, choosing colors for dark mode could be a whole separate article. We won’t get into the specifics here, but check out this article by the team at Superhuman for a great primer).
Each of our semantic colors points to two base palette colors: one for light mode and one for dark mode.
That way, we can just apply a
primaryAction style to our buttons and the right palette variant will render based on the user’s mode.
You may be thinking: “Why can’t you just define a dark mode variant of
primary600? This doesn’t work because there are some colors that should get darker in dark mode (think backgrounds) and some colors that should get lighter in dark mode (think labels and accents).
The two-layer semantic approach to colors allows us to flexibly update colors, keep our app visually consistent, and efficiently design for dark mode.
The next two posts in this series will cover the ins and outs of how this system actually works — for designers and for developers.
Thanks to Alan Dennis (who came up with the structure of this approach) and the rest of the YNAB design and development teams for helping to bring this system to life.
Additional thanks to Alan Dennis, Dylan Mason, and Tristan Harward for giving feedback on the first draft of this post.