Jul 16, 2024

Light and Dark: Our Color System’s Journey

{frontmatter.author}
by Abdulshaqur Suleiman

In recent years, dark mode has become more common across websites and apps. It can provide a more comfortable experience on brighter screens and help reduce eye strain. Additionally, some people also prefer it for its aesthetic appeal. Either way, it’s been a common request from our customers.

So, we recently added dark mode support for our mobile apps. We took this opportunity to establish a more intuitive and reusable color system. This system supported the implementation of our new dark theme and will make it easy to apply to new views and components going forward.

Light and dark mode side by side on mobile

Understanding our existing structure

We started by auditing our existing color system.

old color system using paint chips and some semantic naming

Previously, we used “paint chip” names for a lot of our colors (e.g., Grey/Alto, Grey/Alabaster).

This method was popular several years ago and while the names were distinct, they didn’t communicate anything particularly useful about the colors and their relationship to each other.

As our design system matured, we started to introduce colors using semantic naming (e.g., Text/Light), more in line with where we wanted to go.

What’s the new structure?

The new color system is built on color ramps. A ramp represents a sequence of shades (from light to dark) within the same color family.

colors in the blue color ramp

Each color/hex value within a ramp is identified by a base token, which can then be referenced by semantic tokens.

hex value to base token to semantic token

Base tokens don’t indicate how the color should be used. They only identify which color group/hue the color belongs to and how light or dark it is. For example, --colorGold20 describes both the group/hue (Gold) and its lightness/darkness (20).

Semantic tokens reference the base tokens, providing colors in our system with meaning and predictable behavior. They communicate how our colors should be applied to our components. They also ensure consistent theming across the application, by allowing us to assign different values for various interface themes (e.g., light mode, dark mode).

I’ll give an example of how we structure our semantic tokens later. First, let's build our color ramps.

Building a ramp… a color ramp

The color ramps in our new system are created using the LCH (Lightness, Chroma, Hue) color model, instead of HSL (Hue, Saturation, Lightness).

This is because in HSL, colors with different hues and the same lightness value can be perceived as different levels of lightness by the human eye.

HSL color space vs LCH color space

We used a color picker to generate colors with consistent perceived lightness in each ramp, ensuring we have enough shades to handle any contrast issues and meet WCAG AA standards (minimum contrast ratio of 4.5 to 1).

By maintaining the same lightness values across different hues, we then adjusted the chroma to suit our specific use case for each color.

For example, we increased the chroma in the middle of the red color ramp to create more saturated reds for error states.

all color ramps in our color system

After creating the color ramps, we converted them into our base tokens. Each token is named using a color and lightness level convention, such as --colorGray10. This naming convention provides a clear understanding of each color’s position within its ramp.

Semantic token naming convention

The semantic tokens reference the base tokens and provide the context for their usage, making it easier for our team to maintain consistency across the application.

The structure of a semantic token starts with the element it is applied to, such as a background, text, icon, fill, or border. Then the component the element belongs to, which may have variants and additional states.

structure of a semantic token

For example, consider the semantic token --colorBgTableCellHighlighted

  • Token type: color
  • Element: Bg (short for background)
  • Component: Table
  • Variant: Cell (the table component has variants like header or cell)
  • State(s): Highlighted (the variants of the table could have states like default, hover or highlighted)

Integrating the color tokens into Figma and the code base

Using Figma variables

We utilized Figma's Variables feature, which allows us to add base tokens as variables along with their corresponding hex values. The beauty of Figma Variables is that we can directly reference the base tokens, when adding our semantic tokens.

base tokens overlayed by semantic tokens from figma’s variables table

Each semantic token can have multiple values in Figma, enabling us to assign two values to each semantic token for the light and dark themes.

For example, the semantic token --colorBgTableCellHighlighted references --colorBlue02 in light mode and --colorBlue90 in dark mode.

semantic token referencing base tokens for light and dark mode

This feature allows us to apply colors to our components and switch between themes effortlessly.

Integrating into the codebase

We created a stylesheet with CSS variables for the base and semantic tokens. The base tokens have their values set to the corresponding hex codes.

:root {
      // Examples of base tokens from the Gray color ramp
      --colorGray01: #FCFCFC;
      --colorGray02: #F9F9F9;
      --colorGray05: #F1F1F1;
    }

The semantic tokens are defined with separate sets of variables for light mode and dark mode, using the prefers-color-scheme media query to switch between them.

/* Showing semantic tokens for texts and the base token they reference in light and 
    dark mode */

// Light mode
:root {
    --colorTextDefault: var(--colorGray80); /* Default text color for light mode */
    --colorTextMedium: var(--colorGray60); /* Medium text color for light mode */
    --colorTextLight: var(--colorGray50); /* Light text color for light mode */
}

// Dark mode
@media (prefers-color-scheme: dark) {
    :root {
    --colorTextDefault: var(--colorGray10); /* Default text color for dark mode */
    --colorTextMedium: var(--colorGray30); /* Medium text color for dark mode */
    --colorTextLight: var(--colorGray40); /* Light text color for dark mode */
    }
}

Now to the battlefield 🛡️

We replaced the old color variables with the new system, updated the colors our components referenced, and then went page by page on mobile to rectify visual issues and identify parts of the app still using the old color values.

During testing, we evaluated the new colors to ensure they met accessibility standards and worked well in various contexts, both in light and dark modes.

Moving forward

While the scope of this project was to support dark mode on mobile, our new color system will also make it much easier to roll out to our web and desktop apps. The mobile app served as a good testing ground to determine if this approach would work well, which it did. 🙂