Level Up Primitive Tokens
Rethought color shading logic for automated theming
Product Background
In Part 1, we explored how semantic tokens gave clients fine-grained control over theming specific components in the UI Kit. But not every client needed that level of detail. Many simply wanted to input a single brand color and have the entire UI adapt intelligently.
To make that possible, we needed to build a new foundational layer — one that defines how colors behave at their core, before they’re mapped to any component. This is where primitive tokens come in.
Team
- Keawalee Chantawong, Product Design Team Lead
- Alin Saenchaichana, Product Designer (Me)
- AJ Supanat Pinyo, Product Design Intern
My Role
I explored alternative approaches, including OKLCH, evaluated their feasibility. Ultimately, I designed and prototyped a dynamic HSL shading system that defines shades by target lightness rather than fixed increments — ensuring predictable, scalable color generation across any brand input.
Year
2025
Two-Layer Token Architecture
In the revamped system, we transitioned from a one-layer token structure — where semantic tokens were directly tied to static HEX values — to a two-layer architecture that separates semantic and primitive tokens.

In a one-layer model, a token like btn-fg-primary directly points to a HEX value, which makes color updates manual. In the new two-layer setup, the same token instead references a primitive token such as brand-250, which then holds the HEX value. This abstraction allows us to modify values of primitives without breaking the semantic layer.

btn-fg-primary in one-layer VS two-layer tokens systems.By decoupling meaning from raw values, the two-layer structure makes the system more maintainable, scalable, and themable. It enables global updates through high-level actions — like “set your brand color” — that ripple across every component automatically, ensuring consistent visual identity without manual tuning.
Fixed Baseline Approach - Simple, but Not Usable

Our legacy color system, though technically a one-layer setup, included a basic shading logic. User input was matched to a Default slot, while Shade 1–4 were generated by incrementing the Lightness (L) value in HSL by fixed amounts.

This fixed baseline approach worked fine for mid-tone colors but broke easily at the extremes. If a brand color was already light, increasing lightness quickly clipped to white — making higher shades indistinguishable and often unusable. In the example above, we would end up with an almost all-white palette.
Exploring OKLCH — A Modern Color Model
To overcome these issues, we explored OKLCH, a perceptually uniform color model increasingly adopted by modern design systems.

Evil Martians wrote an excellent deep dive comparing OKLCH with older color spaces like RGB and HSL. In short, HSL is not perceptually uniform — changing the Lightness value doesn’t always look like it’s getting lighter or darker. For example, increasing lightness on a saturated blue barely changes its appearance, while doing the same on a yellow can completely wash it out.
OKLCH fixes this by using parameters that better align with how humans perceive color:
- L (Lightness) — how light or dark a color appears to the human eye
- C (Chroma) — the intensity or vividness of the color
- H (Hue) — the actual color angle (e.g., red, blue, green)
Because of this, colors in OKLCH maintain consistent contrast and visual balance even when lightness or chroma changes, helping with accessibility.

Downsides of OKLCH
- Each OKLCH color has different min and max limits for its lightness and chroma — meaning when generating tints or shades, we can easily end up in a nonexistent color space that must be handled via fallback logic before converting to HEX.
- Adds complexity to Figma and token pipelines. Because our workflow relies heavily on HEX-based tokens for cross-platform theming, OKLCH wasn’t practical for direct adoption.
Instead, we drew inspiration from its perceptual model to design a dynamic HSL-based shading logic — achieving similar visual consistency while keeping implementation simple and compatible.
Mimicking OKLCH with HSL - A Dynamic Baseline Approach
A Primitive Token Walkthrough

Before we get to the shading logic, let's get the big picture of all the primitive tokens. We have a toltal of 5 color sets: brand. grey, red, yellow, and green. Each color set has 15 shades, starting from 50 (lightest) to 950 (darkest).
How Dynamic Baseline Approach Works
- Predefine the Lightness (L) values
We start by defining a fixed set of lightness values (L) for each shade in the color scale.

- Match the input color to the nearest predefined L value
When an input color is provided, its lightness (L) is compared against the predefined scale to find the closest match. Once a match is found, the lightness value of that shade is overwritten with the input’s L—ensuring we preserve the original input color’s tone. In the example below, the input L is49, which matches most closely with the predefined47at shade400.

- Apply saturation (S) decay outward from the matched shade
The input color’s saturation (S) is assigned to the matched shade. The shades before and after it gradually reduce their saturation using a decay factor of 0.95, creating a smoother, more natural transition and preventing excessive vibrancy.

- Keep the hue (H) consistent across all shades
Finally, the hue (H) remains fixed throughout the set to maintain a cohesive color family. With the H, S, and L values defined, we now have a complete color palette generated from a single input color.

Prototyping the Concept with Google Sheets
This concept was not easy to communicate. To help the team and stakeholders grasp how values shift dynamically, I prototyped the logic in Google Sheets, combining formulas and custom scripts to visualize the shade generation in real time.
Outcome
The new Dynamic Baseline HSL logic became the foundation for automated theming in our design system, bridging perceptual accuracy with practical implementation — all while staying fully compatible with Figma and HEX-based pipelines.