Modern CSS Color: OKLCH, LCH, Wide-Gamut, and New Color Functions Explained

For years, color on the web has meant hex codes, rgb(), and hsl(). Those still work, but they were designed for older displays and simpler workflows. Modern CSS gives you better color spaces, better functions, and better ways to express design tokens directly in code.

With today’s platform, you can work in perceptually uniform spaces like LCH and OKLCH, target wide-gamut displays such as Display P3, mix colors in the browser instead of in design tools, and generate entire palettes from a handful of base tokens.

This guide explains the modern CSS color tools that are worth learning now and how to use them in real-world UI work.

Why new color spaces exist

Traditional web color spaces and notations, like sRGB and the hsl() function, are familiar and quick to use in code but not ideal for design systems. Equal numeric steps in HSL lightness or saturation often do not look like equal steps to the human eye. A “10% lighter” color might feel dramatically brighter in one part of the palette and barely different in another. This makes it hard to build consistent scales and to tweak colors for accessibility.

These older spaces are also tied to the sRGB gamut. Many modern displays can show more saturated, vivid colors than sRGB can describe. If your design tool works in a wide-gamut space but your CSS only uses hex or rgb(), you end up clipping or dulling colors at implementation time.

Newer color spaces were introduced to solve these issues. They aim to be more perceptually uniform so that changes in the numbers correspond more closely to changes in appearance. They also behave well on wide-gamut displays: modern screens render the extra saturation, while older ones fall back to the closest sRGB-safe color. For designers and developers, that translates into more predictable color steps, more reliable contrast adjustments, and a smoother bridge between design tools and code.

New CSS color spaces in practice

LCH and LAB

LAB and LCH come from the color science world, long used in print and high-end imaging. CSS exposes them as lab() and lch().

LAB represents colors with three channels: lightness (L) and two opponent color axes (a and b). LCH is simply a different way to express LAB: the same space, but with chroma and hue instead of the a/b axes. In lch(), you provide a lightness value from 0 to 100, a chroma value that controls how intense the color is, and a hue angle in degrees.

A simple example:

button {
  background-color: lch(65% 60 300deg);
}

Here, increasing the first number raises perceived lightness; decreasing the chroma value desaturates the color; changing the hue angle rotates around the color wheel. Because LCH is closer to how human perception works, building a scale by nudging the lightness up or down tends to produce smoother, more even steps than HSL. It is also device-independent, which helps if you need to match brand colors between print and web.

In day-to-day UI work, LCH is handy for tints, shades, and subtle adjustments where you want “two steps lighter” to feel like two steps lighter, not a random jump.

Oklab and OKLCH

Oklab and OKLCH are more recent perceptual spaces designed with modern displays in mind. They aim to behave even more uniformly than LCH, especially when you move across wide gamuts. In CSS, they appear as oklab() and oklch().

The structure is similar to LAB and LCH: OKLCH gives you lightness (L), chroma (C), and hue (h). The mental model is the same: adjust L for brightness, C for intensity, and h for hue.

For example:

:root {
  --brand: oklch(0.65 0.18 260deg);
}

.button {
  background-color: var(--brand);
}

.button:hover {
  background-color: oklch(from var(--brand) calc(L + 0.05) C h);
}

Here, the hover state is generated by slightly increasing the lightness of --brand while leaving chroma and hue alone. Designers like OKLCH because it makes it easier to build complete palettes with consistent contrast and clean progression. If you are creating a fresh design system and you want a single space for all your core tokens, OKLCH is a strong candidate.

The color() function and wide gamuts

The color() function lets you specify a color directly in a named color space. Instead of assuming sRGB, you can say “this is a Display P3 color with these channel values.”

For instance:

.hero {
  background: color(display-p3 0.05 0.7 0.65);
}

The first argument selects the space (srgb, display-p3, and others). The numbers that follow are the channel values in that space.

This is particularly useful when brand guidelines are defined in a wide-gamut space or when you want extra vivid accents that only modern displays can reproduce. Browsers that don’t support color() or a given color space will ignore that declaration, so you can safely put a simpler color first as a fallback.

Even if you never write color(display-p3 ...) by hand, understanding that CSS can now address these spaces makes it easier to coordinate with design tools and to justify wide-gamut work in your system.

Modern CSS color functions

New color spaces are only half the story. CSS also provides functions that let you derive and adjust colors in the browser instead of creating every variant ahead of time in your design tools.

The color-mix() function

color-mix() creates a new color by mixing two existing ones in a particular color space. You specify the target space, then the colors and their proportions.

.card {
  background: color-mix(in oklch, var(--brand) 70%, white);
}

.card--danger {
  background: color-mix(in oklch, var(--brand) 40%, red);
}

In this example, the regular card background is mostly the brand color mixed with some white. The danger variant blends the same brand color with red. Because the mixing happens in OKLCH, the result respects perceptual relationships rather than simple channel averaging.

This is a practical way to generate semantic variants—such as “soft brand background,” “subtle warning tone,” or “overlay color”—without having to hard-code each one. You define a few anchors (--brand, --danger, --surface) and derive the rest. If --brand changes, the entire family of derived colors updates automatically.

Relative color syntax

Relative color syntax lets you express a color in terms of another color rather than as absolute channel values. You use the from keyword inside a color function and then reference the original color’s channels.

A simple example in HSL:

:root {
  --brand: #4f46e5;
}

.button {
  background: hsl(from var(--brand) h s l);
}

.button:hover {
  background: hsl(from var(--brand) h s calc(l + 10));
}

In the first rule, the background is simply the brand color expressed in HSL. In the hover rule, the hue and saturation remain the same while lightness is increased by 10\. You do not need to know the numeric channel values up front; the browser handles the conversion.

The same idea works nicely with OKLCH:

.badge {
  background: oklch(from var(--brand) calc(L + 0.08) C h);
}

This approach is powerful when building design tokens. You can define a single “truth” for a brand color and then derive all tints and shades with small adjustments to just one channel. The code stays DRY, and palette changes are easier to manage.

The light-dark() function

Dark mode typically requires mirrored sets of color rules or long media queries that override base tokens. The light-dark() function simplifies this by bundling two versions of the same color into a single value that automatically switches based on the active color scheme.

:root {
  color-scheme: light dark;
}

body {
  background-color: light-dark(#f5f5f5, #050816);
  color: light-dark(#111111, #f9fafb);
}

Here, the first color in each pair is used in light mode and the second in dark mode. You can pass any color format, including OKLCH or colors generated via color-mix(). Combined with color-scheme, this gives you theme-aware tokens without duplicating entire blocks of CSS.

In a component library, you can take this further by defining --surface, --surface-alt, --text, and similar tokens with light-dark(), then using those tokens everywhere. This keeps theme logic centralized and makes introducing dark mode much less painful.

Building a modern color system

Putting these tools together, you can create a color system that is both more expressive and easier to maintain than a traditional hex-based palette.

Define base tokens in OKLCH

Start by choosing a primary brand color in OKLCH. Use a color picker that supports OKLCH or convert an existing brand color from sRGB to OKLCH and adjust until it feels right.

:root {
  --brand: oklch(0.63 0.19 260deg);
}

You can do the same for secondary, accent, and status colors (for example, --success, --warning, --danger) so everything lives in the same perceptual space.

Generate scales with relative colors

Once you have a base token, generate a scale of tints and shades by manipulating lightness and chroma. Because OKLCH behaves perceptually, those steps are more likely to look even.

:root {
  --brand-50: oklch(from var(--brand) calc(L + 0.24) calc(C - 0.05) h);
  --brand-100: oklch(from var(--brand) calc(L + 0.18) calc(C - 0.04) h);
  --brand-200: oklch(from var(--brand) calc(L + 0.12) calc(C - 0.03) h);
  --brand-300: oklch(from var(--brand) calc(L + 0.06) calc(C - 0.02) h);
  --brand-400: var(--brand);
  --brand-500: oklch(from var(--brand) calc(L - 0.06) C h);
  --brand-600: oklch(from var(--brand) calc(L - 0.12) C h);
}

You now have a sequence of related colors that share hue and feel like a coherent family. You can tune the offsets to hit your desired contrast ratios for text, borders, and surfaces.

Create semantic tokens with color-mix() and light-dark()

With structural scales in place, define semantic tokens that represent intent rather than specific values: surfaces, borders, emphasis states, and so on.

:root {
  color-scheme: light dark;

  --surface: light-dark(#f9fafb, #020617);
  --surface-alt: color-mix(in oklch, var(--surface) 80%, var(--brand) 20%);
  --border-subtle: color-mix(in oklch, var(--surface) 90%, black 10%);

  --danger: oklch(0.64 0.23 25deg);
  --danger-soft: color-mix(in oklch, var(--danger) 15%, var(--surface) 85%);
}

Components then consume these semantic tokens:

.button-primary {
  background: var(--brand-400);
  color: white;
}

.button-primary:hover {
  background: var(--brand-500);
}

.alert-danger {
  background: var(--danger-soft);
  border-color: var(--danger);
}

By separating the low-level where the exact OKLCH values live from the high-level where UI semantics are defined, you create a system that is easier to refactor. Designers can change how “danger” feels without touching every component, and engineers can keep implementation details isolated.

Support, tooling, and accessibility

Browser support and fallbacks

Modern color spaces and functions are implemented in current versions of major browsers, but you may still need to support older environments. The usual strategy is progressive enhancement: define a baseline color in a legacy format and then override it with a modern declaration.

For example:

.button-primary {
  background: #4f46e5; /* fallback */
  background: oklch(0.63 0.19 260deg); /* modern override */
}

If a browser does not understand oklch(), it will ignore that declaration and fall back to the earlier hex value. If you have a build pipeline, you can also use PostCSS or similar tools to preconvert OKLCH and LCH into sRGB for legacy targets, while still authoring in the modern syntax. For sites built on Wirechunk, this is done automatically.

When deciding how aggressively to adopt these features, treat them like you would treat newer layout primitives: start in non-critical areas (for example, subtle gradients or focus rings) and expand as your support matrix allows.

Integrating with design tools

Many design tools now offer some level of support for LAB, LCH, or OKLCH, either directly or via plugins. Even if your team is not fully invested in those spaces, agreeing on a shared model prevents drift between design and implementation.

A common workflow looks like this:

  1. Define core brand and neutral colors in a perceptual space inside the design tool.
  2. Export them as tokens, ideally in a structured format.
  3. Mirror those tokens in CSS with custom properties using oklch() or lch().
  4. Use relative color syntax and color-mix() in CSS to generate context-specific variants (hover, disabled, emphasis backgrounds) rather than hard-coding dozens of separate tokens.

This keeps your palette manageable and makes it easier to review changes, because many variants derive from a small number of roots.

Contrast and accessibility

New color spaces make it easier to adjust colors while keeping them visually coherent, but they do not replace contrast checking. You still need to ensure that text and key UI elements meet appropriate contrast ratios.

The advantage of OKLCH and LCH is that adjusting lightness or chroma tends to produce more predictable improvements. If a button label fails contrast, you can increment lightness of the background or decrease lightness of the text in small steps and visually inspect the result, knowing the hue and saturation won’t unexpectedly shift.

Where possible, incorporate contrast checking into your design pipeline and component library. When a token combination fails, fix it by nudging values in the perceptual space rather than guessing with hex values.

Choosing what to use

You do not need to adopt every modern color feature at once. A simple, pragmatic strategy for most new projects is:

  • Use OKLCH for core design tokens and palette work. It balances expressiveness and predictability and works well across devices.
  • Use relative color syntax to derive tints, shades, and subtle variations from those tokens instead of introducing new hard-coded colors.
  • Use color-mix() when you need blended or contextual colors such as overlays, soft states, or semantic variants that sit between two anchors.
  • Use light-dark() to bridge light and dark themes without duplicating rules, and pair it with color-scheme to let the browser optimize things like form controls and scrollbars.
  • Reach for color() with Display P3 only when you have a clear, design-driven reason to rely on wide gamut, and keep sensible fallbacks for older devices.

If you already have an established palette in hex or HSL, you can introduce these features incrementally. Start with one component, such as the primary button, and define it in OKLCH with a couple of derived states. Once that feels comfortable and you see the benefits in code maintenance, expand outward to surfaces, neutrals, and status colors.

The key shift is to think of color not as static values pasted from a design file but as a structured system that the browser can help you generate and maintain. Modern CSS color functions and spaces give you the tools to do exactly that.