Home / Articles / Animation & Motion /

animation color

Color transitions in different color spaces

Why your blue-to-orange transition looks muddy in sRGB but vibrant in oklch — and how to choose the right interpolation space for smooth color animations.

The problem: color transitions that look wrong

You've picked two vibrant colors: a bright blue and a warm orange. You transition between them with a simple CSS rule. But something's off — the transition passes through a muddy gray-green that feels disconnected from both endpoints. The animation doesn't feel like it's truly moving between your chosen colors.

This isn't a bug — it's the result of how browsers interpolate colors in different color spaces. Different color spaces represent colors differently, and the path between two colors depends on which space the interpolation happens in. Understanding these differences is essential for creating smooth, intentional color transitions.

how color interpolation works

When you animate from one color to another, the browser calculates intermediate values by interpolating each channel. For a blue-to-orange transition in sRGB, it interpolates red, green, and blue channel values linearly. But human perception of color isn't linear — equal numerical changes don't look like equal perceptual changes.

/* Default interpolation: sRGB */
.button {
  background-color: oklch(0.5 0.2 250);
  transition: background-color 2s ease;
}
.button.active {
  background-color: oklch(0.65 0.25 60);
}

/* The browser converts to sRGB, interpolates there,
   then displays the result */

The key insight: all color interpolation in CSS happens in a specific color space, and the choice of that space dramatically affects the visual result. Modern browsers allow you to control this with the color-interpolation and related features.

srgb: the legacy default

sRGB is the default color space for the web. It was designed in 1996 for CRT monitors and has a relatively small gamut. More importantly, its coordinates don't correlate well with human perception — equal numerical changes don't look like equal perceptual changes. This becomes painfully obvious during transitions between colors that are far apart in chromaticity.

/* sRGB transition passes through desaturated colors */
.element {
  transition: color 1.5s linear;
}
/* color: blue (rgb(0, 0, 255)) → color: orange (rgb(255, 165, 0))
   The midpoint is a grayish brown */

The muddy midpoints occur because sRGB treats all three channels equally, while human vision is far more sensitive to green than to red or blue. When interpolating from pure blue to orange, the green channel climbs from 0 to 165 while red climbs from 0 to 255. At the halfway point, you get roughly rgb(128, 82, 128) — a desaturated purple-brown that doesn't feel like either endpoint.

This is why legacy websites often have color transitions that look flat or boring. The sRGB space limits your options — you're stuck with its non-perceptual interpolation. While it still serves as a reliable fallback, relying on sRGB for transitions in 2026 means accepting a noticeably inferior visual experience when modern alternatives exist.

oklch: perceptually uniform interpolation

Oklch is designed for perceptual uniformity. Colors that are closer in oklch space look closer to human eyes. This means interpolating between two oklch colors produces a smoother, more vibrant transition that stays vivid throughout.

/* oklch transitions stay vibrant */
.button {
  color: oklch(0.5 0.2 250);  /* Cool blue */
  transition: color 1.5s;
}
.button.active {
  color: oklch(0.65 0.25 60); /* Warm orange */
  /* Midpoints stay vivid, no muddy grays */
}

The oklch color space has three components: lightness (0-1), chroma (saturation intensity), and hue angle (0-360). When you interpolate, each component changes independently, producing a more natural-looking transition. The chroma channel ensures saturation stays consistent through the animation.

color-mix: explicit interpolation space

With color-mix(), you can explicitly request interpolation in a specific color space. This is particularly useful for gradients and computed color values:

/* Interpolate in oklch space */
.gradient-button {
  background: color-mix(in oklch,
    oklch(0.5 0.2 250),
    oklch(0.65 0.25 60) 50%,
    oklch(0.3 0.15 120)
  );
}

/* Compare with sRGB interpolation */
.gradient-button.bad {
  background: color-mix(in srgb,
    oklch(0.5 0.2 250),
    oklch(0.65 0.25 60) 50%
  );
  /* Muddier midpoints */
}

The in oklch syntax tells the browser to perform the interpolation in oklch space before converting to the display color space. The result: a smoother, more pleasing gradient that maintains saturation.

lab: an alternative to oklch

Lab color space is similar to oklch — both are perceptually uniform and both use the D65 illuminant as a reference white point. Oklch is actually just the cylindrical representation of Lab, converting the rectangular a* and b* coordinates into chroma and hue angle. This means interpolating in Lab produces nearly identical results to interpolating in oklch, just with different mathematical representation.

/* Lab interpolation produces similar results to oklch */
.element {
  color: lab(40% 20 -30);  /* Blue-ish */
  transition: color 1.5s;
}
.element.active {
  color: lab(60% 40 50);   /* Orange-ish */
}

Lab works just as well as oklch for color transitions, but oklch is often preferred because the chroma/hue representation is more intuitive for color selection. If you're already thinking in terms of hue and saturation — common in design tools and color pickers — oklch maps directly to those mental models. With Lab, you must think in terms of a* (green-red axis) and b* (blue-yellow axis), which requires more conversion in your head.

Browser support is essentially identical: Lab has been stable since CSS Color Level 4 shipped in all major browsers. Choose Lab if you're migrating from existing Lab-based design systems. Otherwise, oklch remains the more ergonomic choice for most developers.

display-p3: wide-gamut transitions

Display-P3 is a wider gamut than sRGB, covering roughly 25% more saturated colors. Developed by Apple for their display lineup, P3 access more vibrant greens, cyans, and reds that sRGB simply cannot represent. On compatible displays, P3 transitions look noticeably richer and more vivid.

/* P3 transitions access more vibrant colors */
.vibrant-button {
  background: color(display-p3 0% 50% 100%);
  transition: background-color 2s;
}
.vibrant-button:hover {
  background: color(display-p3 100% 80% 0%);
  /* More saturated than sRGB equivalent */
}

The tradeoff requires careful consideration: P3 isn't supported uniformly across all devices. Older displays clamp P3 colors to sRGB, which can drastically change the appearance of your transition. A vibrant P3 green might appear dull and desaturated on sRGB-only screens, potentially breaking the intended visual narrative. For production deployments, include an oklch() fallback that approximates the P3 gamut on supported browsers while degrading gracefully:

/* Progressive enhancement for P3 */
.vibrant-button {
  background: oklch(0.5 0.15 160);  /* Good baseline */
  background: color(display-p3 0% 65% 60%);  /* Enhanced */
  transition: background-color 2s;
}

Remember that oklch already provides a wider perceptual gamut than sRGB on all modern browsers, making it more portable than P3 for most use cases. Use P3 when you specifically need its extra green-cyan range and have tested the fallback behavior extensively.

hue interpolation and the short-path problem

One of the most subtle issues in color transitions involves hue interpolation. When moving between two hues in a cylindrical space like oklch, the browser must decide whether to rotate clockwise or counterclockwise around the color wheel. For small hue differences, this doesn't matter. But for wide-span transitions — say blue at 250° to orange at 60° — the choice dramatically affects the result.

/* This transition crosses ~190 degrees through purple */
.transition {
  color: oklch(0.5 0.2 250);  /* Blue */
  transition: color 3s;
}
.transition.active {
  color: oklch(0.5 0.2 60);   /* Orange */
  /* Default: shortest path means through purple/magenta */
}

By default, oklch uses the shortest angular distance. Going from 250° to 60° means either rotating forward 170° or backward 190° — so the browser picks forward through purple and magenta. Sometimes that's desirable. But often you want the transition to go through green and yellow instead. Unfortunately, there's no native CSS property to force a specific hue direction yet — developers work around this by adding intermediate stops with color-mix() or multi-step animations that explicitly traverse the desired path.

combining color transitions with other properties

Color transitions rarely happen in isolation. They're typically paired with scale, opacity, or transform changes to create rich microinteractions. The good news: oklch color transitions compose beautifully with these other properties, and when all animations share similar durations and easing curves, they create a cohesive sense of motion.

/* Combining color with scale and shadow */
.card {
  background: oklch(0.95 0.02 250);
  box-shadow: 0 2px 8px oklch(0.5 0 250 / 0.15);
  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.card:hover {
  background: oklch(0.85 0.12 250);
  box-shadow: 0 8px 24px oklch(0.5 0 250 / 0.25);
  transform: translateY(-4px);
}

The key to harmonious multi-property animations is consistent timing. When color changes happen at the same pace as transform and shadow changes, the brain perceives them as parts of a single movement rather than separate effects. Using the same custom easing curve across properties further reinforces this unity. Avoid mixing cubic-bezier curves unless you're intentionally creating staggered or sequenced feedback.

the alpha channel problem

When transitioning colors with transparency, the interpolation space affects alpha blending too. In sRGB, alpha transitions can produce unexpected results due to the non-linear RGB channels. In oklch, alpha blends more naturally with the color channels.

/* Oklch with alpha transitions smoothly */
.fade-button {
  background-color: oklch(0.5 0.2 250 / 0.4);
  transition: background-color 1.5s;
}
.fade-button.active {
  background-color: oklch(0.65 0.25 60 / 0.9);
  /* Both color and alpha interpolate smoothly */
}

Modern browsers now support alpha in color spaces natively. Where oklch isn't available, you may see choppy transitions when opacity is involved — another reason to favor oklch in new code. When animating alpha values alongside color, keep the total animation duration under 500ms for interactive feedback, or over 1.2s for mood-setting atmospheric transitions.

practical strategy for color animations

When designing color transitions, follow this hierarchy of decisions:

  • Use oklch() for all color values — it's supported everywhere color transitions are useful.
  • Choose endpoints with similar chroma values to maintain saturation through the transition.
  • Avoid extreme hue differences (>120°) unless the intermediate colors are acceptable.
  • For multi-step colors, use color-mix(in oklch, ...) to ensure smooth interpolation.
  • Animate transform and color together only when they serve the same narrative purpose.
  • Keep interactive transitions under 300ms for buttons and controls.
  • Use 400–600ms for page-level theme changes or major state shifts.
  • Test on low-end devices — simpler transitions render faster on constrained hardware.
  • For gradients, specify the interpolation space explicitly with color-mix(in oklch, ...).
Remember: browser support for oklch is excellent (Chrome 111+, Firefox 113+, Safari 15.4+). For older browsers, provide a static fallback color rather than a transition. It's better to have a consistent appearance than a janky animation.