Modern CSS colors: oklch() and color-mix()
By Flavio Copes
oklch() gives perceptually uniform colors, and color-mix() blends them. Build palettes and tints with modern CSS color functions in all major browsers.
Modern CSS gives us two excellent tools for working with color:
oklch()describes a color with lightness, chroma, and huecolor-mix()creates a new color by mixing existing colors
Together they make it easier to build consistent palettes, hover states, borders, backgrounds, and dark themes.
They solve two separate jobs:
oklch()gives us a useful way to choose and adjust source colorscolor-mix()derives new colors from the source colors at runtime
If CSS color syntax and custom properties are new to you, start with the free CSS course.
Why hex is difficult to adjust
A hex color such as #2563eb is compact, but its channels describe red, green, and blue light.
.button {
background: #2563eb;
}
You cannot look at that value and know how to make it 15% lighter. You usually open a color picker and save another unrelated hex value.
RGB has the same limitation in a more explicit form:
.button {
background: rgb(37 99 235);
}
HSL is easier to read, but its lightness is not perceptually uniform. A yellow and a blue with the same HSL lightness do not appear equally bright.
oklch() is designed so changes to lightness better match what our eyes perceive.
Read an oklch() color
An OKLCH color contains three channels:
.button {
background: oklch(60% 0.2 255);
}
Read the values as:
60%is lightness0.2is chroma255is the hue angle
Lightness runs from black at 0% to white at 100%.
Chroma controls how colorful the result is. 0 produces gray. Higher values produce more vivid colors, until the requested color goes beyond what the display can show.
Hue is an angle. Colors wrap around after 360 degrees.
When chroma is 0, the color is gray and hue has no visible effect:
.neutral {
color: oklch(60% 0 250);
}
Changing 250 does not change that gray because there is no chroma to give it a hue.
You will commonly see chroma written as a number between 0 and about 0.4. CSS also accepts a percentage, but 100% chroma maps to 0.4, not 1. I prefer the numeric form because most OKLCH palettes use it.
Alpha is optional:
.overlay {
background: oklch(20% 0.04 255 / 70%);
}
If you need the foundations first, read the CSS color guide.
Build a lightness scale
Start with one brand color:
:root {
--brand: oklch(60% 0.2 255);
}
Create a small palette with the same general hue:
:root {
--brand-100: oklch(95% 0.03 255);
--brand-300: oklch(80% 0.1 255);
--brand-500: oklch(60% 0.2 255);
--brand-700: oklch(42% 0.16 255);
--brand-900: oklch(25% 0.08 255);
}
Notice that chroma also changes. Very light and very dark colors usually need less chroma than the main brand color. Keeping a high chroma at every step can produce colors outside the display gamut.
Use the scale in components:
.notice {
color: var(--brand-900);
background: var(--brand-100);
border: 1px solid var(--brand-300);
}
The values are still choices. OKLCH makes those choices easier to reason about. It does not generate an accessible palette automatically.
Convert an existing color without redesigning everything
You do not need to rebuild a palette from scratch.
Start with the color you already use:
:root {
--brand: #2563eb;
}
Use browser developer tools or a color converter to find its OKLCH representation. Save that as the new source token:
:root {
--brand: oklch(54.6% 0.215 263);
}
The exact converted values depend on the source color. Keep the converted color first, then adjust one channel at a time.
My workflow is:
- match the current color
- adjust lightness for the role
- reduce chroma if the color clips
- test contrast in the real component
This preserves the existing visual direction while giving us values that are easier to tune.
Create tints with color-mix()
color-mix() blends colors at runtime.
.button {
background: var(--brand);
}
.button:hover {
background: color-mix(in oklch, var(--brand) 85%, white);
}
This result contains 85% brand color and 15% white.
If you omit one percentage, the browser calculates the remainder. These are equivalent:
color-mix(in oklch, var(--brand) 85%, white 15%)
color-mix(in oklch, var(--brand) 85%, white)
If you omit both, each color contributes 50%:
color-mix(in oklch, var(--brand), white)
Always name the color space. Mixing in oklch and mixing in srgb can produce visibly different paths between the same colors.
Modern CSS has a default mixing space, but writing in oklch makes the decision visible to the next person reading the stylesheet.
Percentages are weights
When one color has a percentage, the other receives the remainder:
color-mix(in oklch, var(--brand) 80%, white)
This is an 80/20 mix.
When neither color has a percentage, both use 50%:
color-mix(in oklch, var(--brand), white)
When both percentages add up to more than 100%, the browser normalizes them. These weights have the same ratio:
color-mix(in oklch, red 60%, blue 40%)
color-mix(in oklch, red 90%, blue 60%)
Do not use oversized weights to communicate brightness. Keep them adding up to 100% when possible so the result is easy to read.
When both percentages add up to less than 100%, the missing amount affects the result’s alpha. That can introduce transparency even when neither source color looked transparent.
Derive component colors from one token
Store the chosen color once:
:root {
--accent: oklch(62% 0.18 145);
}
Derive related colors:
:root {
--accent-hover: color-mix(
in oklch,
var(--accent) 82%,
black
);
--accent-soft: color-mix(
in oklch,
var(--accent) 15%,
white
);
--accent-border: color-mix(
in oklch,
var(--accent) 45%,
white
);
}
Now use those values consistently:
.tag {
color: var(--accent-hover);
background: var(--accent-soft);
border: 1px solid var(--accent-border);
}
Change --accent and the related colors update. This is one of the best uses of CSS variables: a small set of source tokens with derived component values.
Mix toward the surface, not always white or black
White and black are convenient, but a component usually sits on a design-system surface.
Define the surface explicitly:
:root {
--surface: oklch(98% 0.01 255);
--accent: oklch(62% 0.18 145);
}
Create a soft accent from those two tokens:
.notice {
background: color-mix(
in oklch,
var(--accent) 12%,
var(--surface)
);
}
Now the derived background follows the surface. If dark mode changes --surface, the component does not keep mixing toward pure white.
This produces more predictable themes than scattering white and black through component rules.
Mix with transparent carefully
You can create a translucent color:
.overlay {
background: color-mix(
in oklch,
var(--brand) 60%,
transparent
);
}
This is not always the same visual result as mixing the brand with the page background. Transparency lets whatever sits behind the element affect the final color.
For a fixed light background, mix with that background color instead:
.panel {
--surface: white;
background: color-mix(
in oklch,
var(--brand) 10%,
var(--surface)
);
}
Use transparency when you want the background to show through. Use an explicit surface color when you want a predictable result.
Create a dark theme
Color mixing works well with theme tokens.
:root {
--surface: oklch(98% 0.01 255);
--text: oklch(22% 0.02 255);
--accent: oklch(60% 0.2 255);
}
@media (prefers-color-scheme: dark) {
:root {
--surface: oklch(18% 0.02 255);
--text: oklch(92% 0.01 255);
--accent: oklch(72% 0.14 255);
}
}
Do not create dark mode by mechanically inverting every lightness value. Dark interfaces usually need lower chroma, softer borders, and carefully chosen surfaces.
Derive component colors after the theme tokens change:
.button {
color: var(--surface);
background: var(--accent);
border-color: color-mix(
in oklch,
var(--accent) 70%,
var(--text)
);
}
The same component rule now uses the active light or dark tokens.
Watch the display gamut
OKLCH can describe colors that an sRGB display cannot reproduce.
For example, increasing chroma may stop producing a visible difference because the browser maps the requested color into the available gamut.
.very-vivid {
color: oklch(70% 0.4 145);
}
Use browser developer tools to inspect the value. Reduce chroma when a color clips or shifts in an unexpected way.
Wide-gamut screens can show more colors, but your palette should still make sense on ordinary displays.
Gamut mapping can change the final color
The browser must render every requested color on the current display.
If an OKLCH value falls outside the available gamut, the browser maps it to a displayable color. The final value can have less chroma or a slightly different appearance than you expected.
This is why raising chroma does not always make a color look more vivid:
.swatch-one {
background: oklch(70% 0.25 145);
}
.swatch-two {
background: oklch(70% 0.4 145);
}
On one display the difference may be obvious. On another, both requests can end up near the same gamut boundary.
Do not build a palette by maximizing chroma. Choose the lowest chroma that gives the intended result across the screens you support.
Color does not guarantee contrast
Perceptual lightness makes palettes more consistent. It does not prove that text meets an accessibility contrast target.
Test text against its real background. Test normal, hover, active, disabled, light-theme, and dark-theme states.
Do not assume two colors are readable because their OKLCH lightness values look far apart. Font weight, font size, transparency, and the final rendered color all matter.
Also test derived states. A hover color mixed with the surface can reduce contrast even when the original accent passes.
The same applies to focus rings, disabled text, and borders that communicate state.
Add a fallback when you need one
Current browsers support oklch() and color-mix(). A simple fallback is still easy for older environments:
.button {
background: #2563eb;
background: oklch(60% 0.2 255);
}
Browsers that do not understand OKLCH ignore the second declaration and keep the hex color.
For a derived color, use @supports:
.button:hover {
background: #1d4ed8;
}
@supports (color: color-mix(in oklch, red, blue)) {
.button:hover {
background: color-mix(
in oklch,
var(--brand) 82%,
black
);
}
}
Keep the fallback before the modern declaration. CSS uses the last valid declaration, so supporting browsers take the new value and older browsers keep the first one.
For a complete token set, define fallback tokens together:
:root {
--brand: #2563eb;
--brand-soft: #dbeafe;
}
@supports (color: oklch(50% 0.1 200)) {
:root {
--brand: oklch(54.6% 0.215 263);
--brand-soft: color-mix(
in oklch,
var(--brand) 15%,
white
);
}
}
Components only read the tokens. The compatibility logic stays in one place.
Common mistakes
The first mistake is treating OKLCH values like guarantees. Perceptual lightness helps us reason about a palette, but it does not guarantee contrast or identical rendering on every display.
The second is keeping the same high chroma at every lightness. Very light and very dark colors often need lower chroma to stay inside the display gamut.
The third is mixing with transparent when we wanted a solid tint. Transparency includes whatever sits behind the element in the final result.
The fourth is creating every state with a percentage formula. Designers still need to look at hover, active, focus, disabled, and dark-mode states. A generated color can be mathematically consistent and visually wrong.
The fifth is mixing in an accidental color space. Write in oklch or another deliberate space so the interpolation path is part of the code.
How I would introduce these colors
I would not convert a whole site in one large change.
I would start with one accent token and one component. I would derive its hover and soft-background states with color-mix(), then test both themes and contrast.
Once that small system works, I would move the main source colors to oklch(). Existing hex fallbacks can stay underneath while older browser support matters.
This keeps the change reviewable. It also shows whether derived colors improve the design before every component depends on them.
My advice is to start with color-mix() for hover states and soft backgrounds. Once that feels natural, move the source palette to oklch() so lightness and chroma become deliberate design choices.