Modern colour: color-mix(), relative colours and light-dark()

Derive a full palette from a few tokens in CSS, and build dark mode with color-scheme and light-dark().

  • Beginner
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Derive colour variants in CSS with color-mix()
  • Adjust colours with relative colour syntax
  • Implement dark mode with color-scheme
  • Provide fallbacks for newer colour features

Design systems used to define every shade by hand: a hover colour, a pale background, a border tint, then all of them again for dark mode. Modern CSS can calculate variants from a few base tokens, and switch between light and dark values with one function.

color-mix()

color-mix() blends two colours in a colour space you choose:

CSS
:root {
    --brand: oklch(50% 0.15 255);
}

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

.button:hover {
    background-color: color-mix(in oklch, var(--brand), black 20%);
}

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

The syntax is color-mix(in <space>, colour1 percentage, colour2 percentage). Percentages say how much of each colour goes in. If you give only one, the other gets the rest: white 85% means 15% brand and 85% white.

Choosing the colour space

The space changes the result. srgb mixes like old-fashioned RGB maths and can produce dull, greyish midpoints. oklch and oklab are perceptual, so tints and shades look more even. oklab is a good default for mixing with white or black; oklch keeps hues more vivid when mixing two different colours.

Transparent variants

Mixing with transparent creates a semi-transparent version of any colour, even one stored in a custom property:

CSS
.focus-ring {
    box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand) 35%, transparent);
}

Before color-mix(), this needed a separate token or a colour stored as raw channel numbers.

Relative colour syntax

Relative colour syntax takes an existing colour, breaks it into channels, and lets you rebuild it with changes. Write from followed by the source colour, then the channel values:

CSS
:root {
    --brand: oklch(50% 0.15 255);
}

.lighter {
    color: oklch(from var(--brand) calc(l + 0.2) c h);
}

.muted {
    color: oklch(from var(--brand) l calc(c * 0.4) h);
}

.complement {
    color: oklch(from var(--brand) l c calc(h + 180));
}

.half-transparent {
    color: rgb(from var(--brand) r g b / 50%);
}

Inside the function, the letters l, c and h (or r, g, b, or h, s, l for hsl) are the source colour's channels, converted into the function's space. You can keep them, replace them or calculate new values with calc().

In oklch(from ...), lightness is a number from 0 to 1, so calc(l + 0.2) adds 20% lightness. Because oklch is perceptual, "20% lighter" looks similar whatever the starting hue.

TaskSimplest tool
Tint or shade towards white or blackcolor-mix()
Transparent version of a tokencolor-mix() with transparent, or rgb(from ... / 50%)
Change one channel precisely: lighter, less saturated, rotate hueRelative colour syntax

A palette from one token

CSS
:root {
    --brand: oklch(52% 0.16 255);

    --brand-100: color-mix(in oklab, var(--brand), white 85%);
    --brand-300: color-mix(in oklab, var(--brand), white 45%);
    --brand-500: var(--brand);
    --brand-700: color-mix(in oklab, var(--brand), black 30%);
    --brand-900: color-mix(in oklab, var(--brand), black 60%);
}

Change --brand and the whole scale follows. That is especially useful for themeable products or white-label sites.

Calculated colours are not automatically accessible. A shade that looks fine on one brand colour may fail contrast on another. Check text and background pairs with a contrast checker, as the Web Accessibility Essentials course explains, and fix any combination that falls short.

Dark mode with color-scheme

The first step in dark mode is telling the browser your page supports it:

CSS
:root {
    color-scheme: light dark;
}

This one declaration does a lot. The browser uses dark versions of its own defaults when the user prefers dark: the page background, default text, form controls and scrollbars. Without it, a dark page often has bright white inputs and scrollbars.

You can also add it in HTML so the browser knows before the CSS loads:

HTML
<meta name="color-scheme" content="light dark">

light-dark()

light-dark() takes two colours and picks the first in light mode and the second in dark mode:

CSS
:root {
    color-scheme: light dark;

    --colour-text: light-dark(#1f2933, #e4e7eb);
    --colour-surface: light-dark(#ffffff, #121a24);
    --colour-muted: light-dark(#52606d, #9aa5b1);
    --colour-action: light-dark(oklch(48% 0.15 255), oklch(75% 0.12 255));
}

body {
    color: var(--colour-text);
    background-color: var(--colour-surface);
}

The function reads the element's used colour scheme, which comes from color-scheme. So light-dark() does nothing useful without color-scheme set. That is the most common reason it "does not work".

Compare it with the media query approach, which you will meet in the Responsive Web Design course:

CSS
:root {
    --colour-text: #1f2933;
    --colour-surface: #ffffff;
}

@media (prefers-color-scheme: dark) {
    :root {
        --colour-text: #e4e7eb;
        --colour-surface: #121a24;
    }
}

Both work. light-dark() keeps each pair of values together on one line, which is easier to maintain.

light-dark() only accepts colours. For other values, such as a different image or shadow in dark mode, use the prefers-color-scheme media query.

A theme switcher

Users often want to override the system setting for one site. Because light-dark() follows color-scheme, a switcher only needs to change that one property:

CSS
:root {
    color-scheme: light dark;
}

:root[data-theme="light"] {
    color-scheme: light;
}

:root[data-theme="dark"] {
    color-scheme: dark;
}
JavaScript
const select = document.querySelector('#theme');

select.addEventListener('change', () => {
    document.documentElement.dataset.theme = select.value; // "light", "dark" or "auto"
});

With data-theme="auto", neither override matches, so the system preference applies. Every light-dark() value and every built-in control updates automatically. In a real project, also save the choice, for example in localStorage, and apply it before the page renders to avoid a flash of the wrong theme.

You can also scope a scheme to part of a page. .promo { color-scheme: dark; } gives that section dark values from every light-dark() token, even on a light page.

Browser support and fallbacks

color-mix(), relative colour syntax and light-dark() are all supported in current versions of the major browsers, but they arrived at different times and relative colour syntax is the newest. Check each one's Baseline status on web.dev.

Unsupported colour functions make the declaration invalid. Provide a static value first:

CSS
.button:hover {
    background-color: #0a3f73;
    background-color: color-mix(in oklch, var(--brand), black 20%);
}

Be careful with tokens. If --brand-700 is defined with color-mix() and the browser does not support it, the custom property still holds that text, and every var(--brand-700) becomes invalid at computed-value time. The earlier fallback declaration does not help in that case, because the property was valid at parse time. Wrap token definitions in @supports instead:

CSS
:root {
    --brand-700: #0a3f73;
}

@supports (color: color-mix(in oklab, red, blue)) {
    :root {
        --brand-700: color-mix(in oklab, var(--brand), black 30%);
    }
}

Debugging colour

  • Click a colour swatch in the Styles pane to see the computed colour. Chrome and Edge also show the contrast ratio of text against its background.
  • Use the DevTools rendering emulation (command menu, "Emulate CSS prefers-color-scheme: dark") to test dark mode without changing your system.
  • If light-dark() always shows the light value, check color-scheme on the element or an ancestor.

Summary

  • color-mix() blends two colours in a chosen space; mix with white, black or transparent for variants.
  • Relative colour syntax (oklch(from var(--brand) l c h)) adjusts individual channels.
  • color-scheme: light dark enables dark defaults for the page and its built-in controls.
  • light-dark(a, b) picks a colour per scheme and depends on color-scheme.
  • Provide static fallbacks, use @supports around tokens, and check contrast of every derived pair.

Further reading