erojas.devertek.io logo
Building a Modern Theme System with OKLCH, color-mix(), and CSS Relative Colors

The Old Way: Hardcoded Hex Colors

I used to hardcode every color variant:

1:root {
2  --color-bg-primary: #ffffff;
3  --color-bg-secondary: #f9f9f9;
4  --color-text-primary: #213547;
5  --color-text-secondary: #888;
6  --color-accent: #646cff;
7  --color-accent-hover: #747bff;
8  --color-border: rgba(0, 0, 0, 0.1);
9}
10[data-theme="dark"] {
11  --color-bg-primary: #242424;
12  --color-bg-secondary: #1a1a1a;
13  --color-text-primary: rgba(255, 255, 255, 0.87);
14  --color-text-secondary: rgba(255, 255, 255, 0.6);
15  /* ... and so on */
16

Issues & The New Approach

Previously, theming relied on manual hex calculations and HSL adjustments that weren’t perceptually uniform, leading to inconsistent brightness and contrast across components. Changing one color often required manually updating multiple variants, and dark mode had to duplicate logic with separate color sets. To solve this, the new approach combines three modern CSS features — OKLCH for perceptually uniform color definitions, color-mix() for intuitive color blending, and relative colors for generating context-aware variations (like borders, hovers, and shadows). Together, these enable dynamic, scalable theming with minimal code and perfect visual harmony across light and dark modes.

Why OKLCH?

OKLCH is perceptually uniform—equal lightness steps look equally different:

1/* HSL - lightness changes aren't perceptually uniform */
2--color-hover: hsl(from var(--primary) h s calc(l - 10%));
3/* Same lightness change looks different depending on hue! */
4/* OKLCH - perceptually uniform */
5--color-hover: oklch(from var(--primary) calc(l - 0.1) c h);
6/* Same lightness change looks consistent across all hues! */

OKLCH Syntax

The OKLCH color model defines colors based on human perception rather than raw RGB values, resulting in more natural and consistent contrast across themes. Its syntax follows the structure oklch(lightness chroma hue), where Lightness ranges from 0% (black) to 100% (white), Chroma controls saturation from 0 (gray) to around 0.4+ (highly vivid), and Hue represents the position on the color wheel in degrees (0–360). This model makes it easier to adjust brightness or saturation while maintaining visual harmony, which is especially useful for generating theme variations like backgrounds, borders, and hover states.

My New Theme System

Step 1: Define Base Colors in OKLCH

1:root {
2  /* Only three base colors to manage! */
3  --color-primary: oklch(65% 0.15 250);        /* Main accent - blue */
4  --color-base: oklch(100% 0 0);               /* Pure white */
5  --color-text-base: oklch(25% 0 0);           /* Dark gray */
6}

Step 2: Use Relative Colors for Component Adjustments

Relative colors let you adjust individual components:

1/* Accent variations - adjust lightness while keeping hue/chroma */
2--color-accent: var(--color-primary);
3--color-accent-hover: oklch(from var(--color-primary) calc(l - 0.1) c h);
4--color-accent-light: oklch(from var(--color-primary) calc(l + 0.15) c h);
5--color-accent-dark: oklch(from var(--color-primary) calc(l - 0.15) c h);

Breaking it down:

  • from var(--color-primary) — source color

  • l, c, h — lightness, chroma, hue from the source

  • calc(l - 0.1) — adjust lightness by 10%

Step 3: Use color-mix() for Tinting and Shading

color-mix() blends two colors:

1/* Background variations - mix base with text color for subtle tints */
2--color-bg-primary: var(--color-base);
3--color-bg-secondary: color-mix(in oklch, var(--color-base), var(--color-text-base) 5%);
4--color-bg-tertiary: color-mix(in oklch, var(--color-base), var(--color-text-base) 10%);

Syntax: color-mix(in color-space, color1 percentage, color2 percentage)Why in oklch? Mixing in OKLCH keeps results perceptually uniform.

Step 4: Combine Both Techniques

1:root {
2  /* Base Colors using OKLCH */
3  --color-primary: oklch(65% 0.15 250);
4  --color-base: oklch(100% 0 0);
5  --color-text-base: oklch(25% 0 0);
6  /* Accent variations - relative colors for precise adjustments */
7  --color-accent: var(--color-primary);
8  --color-accent-hover: oklch(from var(--color-primary) calc(l - 0.1) c h);
9  --color-accent-light: oklch(from var(--color-primary) calc(l + 0.15) c h);
10  --color-accent-dark: oklch(from var(--color-primary) calc(l - 0.15) c h);
11  /* Background variations - color-mix for tinting */
12  --color-bg-primary: var(--color-base);
13  --color-bg-secondary: color-mix(in oklch, var(--color-base), var(--color-text-base) 5%);
14  --color-bg-tertiary: color-mix(in oklch, var(--color-base), var(--color-text-base) 10%);
15  /* Text variations - relative colors for lighter shades */
16  --color-text-primary: var(--color-text-base);
17  --color-text-secondary: oklch(from var(--color-text-base) calc(l + 0.3) c h);
18  --color-text-muted: oklch(from var(--color-text-base) calc(l + 0.45) c h);
19  /* Border - relative color with opacity */
20  --color-border: oklch(from var(--color-text-base) calc(l + 0.5) c h / 0.3);
21}

The Magic: Dark Theme

Override only the base colors:

1[data-theme="dark"] {
2  --color-base: oklch(20% 0 0);      /* Dark background */
3  --color-text-base: oklch(90% 0 0);  /* Light text */
4  /* That's it! Everything else recalculates automatically */
5}

When to Use Each Technique

Use Relative Colors When:

  • You need precise component adjustments

  • You want to maintain hue/chroma relationships

  • You need lightness-only changes

Use color-mix() When:

  • You want to tint (mix with white)

  • You want to shade (mix with black)

  • You want to blend two colors

Benefits

Using OKLCH and CSS relative colors brings a series of powerful improvements to theming. First, it’s perceptually uniform, meaning OKLCH preserves consistent lightness and contrast across hues. It also results in less code, reducing the need for dozens of hardcoded color tokens down to just a few base colors with automatically derived variants. Maintenance becomes easier, since updating a single base color cascades through all related shades. Dark mode support is better and cleaner—you only override the base colors, and everything else adapts naturally. The system is also more flexible, allowing independent control over lightness, chroma, and hue to fine-tune appearance. Finally, it requires no JavaScript, keeping theming fast, lightweight, and entirely handled by CSS.

What I Learned

Working with OKLCH, color-mix(), and relative colors completely changed how I think about theming in CSS. I learned that OKLCH is absolutely worth adopting for achieving consistent color perception across hues and lightness levels. color-mix() made tinting and shading effortless compared to manual hex or HSL adjustments, while relative colors offered precise, context-aware control over component styling. Combining these techniques covers a wide range of use cases—from subtle hover states to full theme palettes. The syntax can look intimidating at first, but once you understand expressions like oklch(from var(--color) calc(l - 0.1) c h), it becomes surprisingly intuitive.

The Result

The new theme system is cleaner (less code, clearer intent), more maintainable (update one base color and all variants adjust automatically), more flexible (fine-tune relationships between shades), more consistent (thanks to perceptually uniform color math), and future-proof, leveraging modern CSS standards that are here to stay. This approach scales elegantly as designs evolve — making it both practical and powerful for modern front-end development.

Have you tried OKLCH or color-mix() in your own projects? I’d love to hear your thoughts and experiences with these new CSS color functions!

theme system