Animating Color in OKLCH Without the Flash
RGB color transitions muddy through gray. Interpolate OKLCH with @property, respect prefers-reduced-motion, and stop the flash.
The theme toggle animated over three hundred milliseconds. Users reported a flash—not a smooth crossfade but a sickly gray pulse between light and dark surfaces. DevTools showed elegant transition declarations on the root element. The browser was doing exactly what you asked: interpolating custom property values as if they were generic strings or sRGB channels, because unparsed custom properties do not carry color-space metadata through the transition engine unless you register them.
Color transitions fail quietly the same way static gradients fail loudly: the space you interpolate in decides whether the journey looks intentional or like a dirty light leak. MDN documents transition timing and property lists thoroughly but rarely warns that background-color between two OKLCH literals may still route through legacy interpolation when the animated value flows through an unregistered custom property on a parent while the child inherits via var(). Teams ship theme toggles that look correct as static endpoints and broken in motion.
This essay covers why RGB and untyped custom properties produce gray flashes, how CSS Houdini @property with typed color syntax wires OKLCH paths through token changes, how prefers-reduced-motion interacts with saturation swings, and production patterns that survive dark mode toggles and hover ramps without animating every SVG stroke on the page. The gray flash is not inevitable. It is a color-space wiring bug you can close with registration, ownership of which element transitions, and disciplined scoping.
Why the midpoint turns muddy
When a browser interpolates between two colors in sRGB, the default for color and background-color unless Color 4 interpolation hints apply on explicit color values, it blends red, green, and blue channels independently. Two vivid hues at opposite sides of the wheel—brand blue and brand orange—do not meet in a vivid midpoint. They meet in desaturated brown-gray because the straight line through RGB space cuts through the interior of the gamut cube. The same geometry breaks lightness ramps that cross hue families. Animating a surface from cool blue-tinted white to warm dark navy in sRGB can flash through muddy purple-gray even when both endpoints look clean as static swatches.
HSL transitions rotate hue while holding saturation and lightness nominally steady, but HSL axes are perceptually non-uniform. A smooth HSL transition can still dip chroma unpredictably. Designers notice as a flash or dirt pulse mid-animation. OKLCH, the cylindrical form of Oklab, interpolates along lightness, chroma, and hue paths tuned for perceptual uniformity. Midpoints stay on-hue. Theme toggles and hover darkening look like deliberate shifts, not like someone dimming the wrong channel. Static OKLCH in stylesheets does not automatically fix animated custom properties. Registration and transition ownership address that gap.
Problems cluster around custom properties. You set –surface-0 on :root, toggle it under data-theme dark, and transition background-color on body. Depending on engine and whether transition is declared on the element whose computed value changes, you may see stepped jumps or sRGB interpolation. Teams report the gray flash most often when token values change on a parent without @property registration, when transition lists background-color on a child that only inherits via var(), and when multiple tokens change simultaneously with identical duration but mismatched perceptual paths. Avoid transitioning all. It pulls border-color, box-shadow, and outline along for the ride, producing extra gray flashes from unrelated channels.
@property and owning the interpolated value
Houdini @property registers custom properties with syntax, inherits, and optional initial-value. Registering a color token tells the animation engine to treat transitions and keyframes as color interpolation rather than string crossfade. With syntax color, engines that implement Color 4 interpolation on custom properties walk the OKLCH path between registered color values when both endpoints are expressed in OKLCH. The gray flash on theme toggle often disappears after registration.
Caveats remain. @property requires Houdini support in Chromium-family browsers; Safari added support in recent releases. Unsupported browsers ignore registration but still apply custom properties, so fallback is instant toggle or sRGB transition. initial-value must be valid color syntax. Register every token you intend to animate: surface steps, text steps, border subtle, accent, semantic status colors if they crossfade on theme change. One missing registration in a bundle of five simultaneous token flips can leave one muddy seam visible on every toggle.
Fix pattern A transitions on the element that owns the property: body carries –surface-0, background-color uses var, transition lists background-color and color on body, dark theme overrides apply on body or root with inheritance you can trace in computed styles. Fix pattern B registers tokens with @property so interpolation is typed as color even when the toggle happens on an ancestor. Hover darkening via unregistered relative color can jump; registering –accent and transitioning either the custom property directly where supported or background-color between two explicit oklch literals on hover reduces surprise.
@property --surface-0 {
syntax: "<color>";
inherits: true;
initial-value: oklch(0.97 0.01 260);
}
@property --text-primary {
syntax: "<color>";
inherits: true;
initial-value: oklch(0.22 0.02 260);
}
:root {
--surface-0: oklch(0.97 0.01 260);
--text-primary: oklch(0.22 0.02 260);
}
[data-theme="dark"] {
--surface-0: oklch(0.16 0.02 260);
--text-primary: oklch(0.93 0.01 260);
}
body {
background-color: var(--surface-0);
color: var(--text-primary);
transition: background-color 0.4s ease, color 0.4s ease;
}
Text and surface should not fight. If background-color animates over four hundred milliseconds but color snaps instantly, readability collapses mid-transition and WCAG contrast can fail on intermediate frames. Pair durations and easing on background-color, color, and border-color when borders carry semantic weight. Scrub the transition at fifty percent in DevTools animation inspector. If contrast ratio drops below four point five to one for body text, shorten duration, reduce lightness delta between modes, or snap text color while only animating surfaces. Semantic status banners should transition background, text, and icon fill via currentColor together so success green does not momentarily pair with error-era text tokens.
prefers-reduced-motion and gradient limits
MDN documents prefers-reduced-motion as a user preference for minimizing non-essential motion. Vestibular disorders and migraine triggers are real. Color flashes count as motion when saturation and lightness swing rapidly across large deltas. Wrapping transitions in a no-preference media query and setting transition none under reduce respects the preference. Do not replace reduced motion with a longer flash. Snap instantly or use a very short fade at fifty milliseconds or less if product insists on softness. Some teams opacity-crossfade a wrapper instead of interpolating hue under reduce.
Also respect prefers-reduced-motion for keyframes cycling accent colors on alerts, loading shimmer gradients that traverse hue, and chart color morph animations. Provide static end states. WCAG 2.3.3 Animation from Interactions at Level AAA limits motion triggered by interaction; color pulses on every hover may need suppression in enterprise accessibility reviews.
Gradients compound the problem. @property on stop colors plus background-image transition rarely interpolates smoothly; browsers may not tween gradient stops. Prefer crossfading two layered backgrounds or animating opacity on an overlay. Static gradient interpolation theory in Gradients Without the Gray Dead Zone explains why in oklch matters for endpoints; animated gradients need separate surface color transition from gradient crossfade rather than one transition on background-image.
A pseudo-element overlay carrying the gradient while the parent keeps a solid surface token is a durable pattern. Surface color transitions on the parent in OKLCH; gradient presence transitions via opacity on the child. Users perceive coordinated theme change without forcing the engine to interpolate stop lists that were never designed to tween.
Register grad-a and grad-b with the same syntax color treatment as surface tokens if those stops ever animate independently. Partial registration across a gradient stack revives muddy midpoints on one stop only.
Hero sections that combine gradient crossfade with surface transition should be tested at fifty percent scrub separately for each layer. One clean layer does not excuse a dirty layer stacked above it.
.surface {
position: relative;
background-color: var(--surface-0);
}
.surface::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(in oklch to right, var(--grad-a), var(--grad-b));
opacity: 0;
transition: opacity 0.4s ease;
}
[data-theme="dark"] .surface::before {
opacity: 1;
}
color-mix in oklch as a hover state is static unless you animate mix percentage via registered @property as number. More often define explicit –accent-hover token and transition between two OKLCH endpoints. Mix shines for authoring; transition shines for runtime animation between known stops.
Production token pattern and debugging
Scope transitions narrowly in real projects. Illustrative universal selectors on root star animate every SVG stroke on theme flip and cost compositor time without user benefit. Target body, card shells, buttons, and navigation regions where motion communicates state. Baseline fallback is instant theme swap with no flash and no motion. Enhancement layer adds @property plus transitions behind supports or progressive Houdini detection. Document in the design system that hover color shifts are decorative; focus rings must remain instant and high-contrast per focus-visible guidelines.
Debugging starts in Chrome DevTools Animations panel: step through frames, sample computed background-color mid-transition. If midpoint hex desaturates, you are on sRGB path. Toggle @property off temporarily; if flash changes character, your engine was string-interpolating custom properties.
Reduce simultaneous token count: animate surfaces first, borders follow fifty milliseconds later to hide composite mud. Check forced-colors mode; Windows High Contrast overrides tokens and transitions may clamp unexpectedly.
Ship a no-motion baseline first, then add @property enhancement in a second pass so users without Houdini support never inherit the worst sRGB flash as default.
@property --surface-0 { syntax: "<color>"; inherits: true; initial-value: oklch(0.97 0.01 260); }
@property --surface-1 { syntax: "<color>"; inherits: true; initial-value: oklch(1 0 0); }
@property --text-primary { syntax: "<color>"; inherits: true; initial-value: oklch(0.22 0.02 260); }
@property --border-subtle { syntax: "<color>"; inherits: true; initial-value: oklch(0.88 0.01 260); }
:root {
--surface-0: oklch(0.97 0.01 260);
--surface-1: oklch(1 0 0);
--text-primary: oklch(0.22 0.02 260);
--border-subtle: oklch(0.88 0.01 260);
}
[data-theme="dark"] {
--surface-0: oklch(0.16 0.02 260);
--surface-1: oklch(0.20 0.02 260);
--text-primary: oklch(0.93 0.01 260);
--border-subtle: oklch(0.28 0.02 260);
}
@media (prefers-reduced-motion: no-preference) {
body, .card, .btn {
transition-property: background-color, color, border-color;
transition-duration: 0.35s;
transition-timing-function: ease;
}
}
@media (prefers-reduced-motion: reduce) {
body, .card, .btn {
transition-duration: 0.01ms !important;
}
}
Register surface and text tokens before accent if you must phase rollout. Partial registration is worse than none when marketing films the toggle for launch video and catches one muddy frame on navigation border tokens you forgot to register.
Case study: The gray pulse on a fintech toggle
A consumer fintech shipped a dark mode toggle on its logged-in dashboard in Q2, using OKLCH tokens exported from Figma plugins because static swatches looked perceptually even across surface steps. Light mode used cool near-white surfaces around oklch lightness zero point nine seven with hint hue two sixty. Dark mode dropped to lightness zero point one six with the same hue anchor. Design filmed a thirty-second launch clip of the toggle for social. Comments praised static endpoints. Support tickets within a week mentioned a flash that made the app feel broken during theme change, especially on OLED Android devices.
Engineering had placed transition background-color zero point three seconds ease on html root while body used background-color var(–surface-0). Tokens flipped on data-theme dark on documentElement. Computed styles on body showed OKLCH endpoints, but mid-transition samples in Animations panel showed desaturated blue-gray halfway, classic sRGB interpolation of custom properties without registration. Color on body snapped instantly because only background-color was listed in transition. Several intermediate frames failed contrast between muted text token and mid-gray surface. Accessibility review flagged the issue as motion plus readability, not merely aesthetic.
The fix sprint registered –surface-0 through –surface-3, –text-primary, –text-muted, and –border-subtle with @property syntax color. Transitions moved to body and main shell components with paired background-color, color, and border-color durations at three hundred fifty milliseconds ease. Accent buttons stopped using transition all; hover darkened via explicit –accent-hover endpoint. prefers-reduced-motion reduce path set transition duration to one millisecond with important where cascade fought utility classes. Gradient marketing hero on the logged-out page used ::before opacity crossfade instead of animating gradient stops after engineers confirmed background-image did not tween.
QA rescinded the launch clip and reshot after fix. Fifty-percent scrub showed midpoints staying on hue two sixty without brown mud. Tickets about flashing dropped to zero in two weeks. One regression appeared when a contractor added –chart-grid token without registration while animating grid lines on theme toggle; muddy pulse returned only on analytics tab. Lint rule in CI flagged new color custom properties in theme files missing matching @property blocks. Design system doc added a sentence: static OKLCH does not imply animated OKLCH.
The team learned that theme toggles are interpolation tests, not swatch comparisons. Figma endpoints lie about motion because Figma does not run your var() inheritance graph. The fintech kept OKLCH for static tokens and earned the right to animate them by registering every token that moves, pairing text and surface timing, and scoping motion away from charts and focus rings. Users who requested reduced motion got instant swaps without being punished by a slower dirt pulse, which had been an earlier misguided compromise.
Color animation is interpolation-first engineering. Register tokens with @property, transition color and background-color on elements that own the changing computed values, interpolate in OKLCH, cut motion for users who request it, and crossfade gradients instead of pretending stop lists tween. The sickly gray pulse between modes is a wiring bug. Close it before you film the toggle for launch.