Color State Morph
animateColorAsState walking through a palette of theme colors. Pick a swatch, the background and accents follow.
You've used animateColorAsState before. Bind a Color to a target, and Compose smoothly morphs from the previous value to the new one whenever state changes. The surface API hides almost everything: there is no animation object to manage, no manual interpolation, no listener to detach. The deeper question is how a single line of code can interpolate between two colors in a way that looks right to the eye, and what role the animation spec plays in shaping that motion.
In this article, you'll explore a small palette picker built on animateColorAsState, examine how the call site translates a state change into a smooth transition, look at the role of color space conversion under the hood, and study how the COLOR_TRANSITION_MS constant and the PALETTE list shape the showcase you see on screen.
How the example is structured
The example renders three things stacked vertically. A title at the top, a hero Box that fills the width and stands at a fixed height, and a row of circular swatches below. Each swatch corresponds to one entry in the PALETTE list. Tapping a swatch updates selectedIndex, which is the only piece of state that drives the entire screen.
When the index changes, two things follow. The hero background morphs to the new color, and the centered label inside the hero morphs to a contrasting foreground color. Both transitions run through animateColorAsState, which means the swatch tap does not call any animation API directly. It only sets state. The animation runtime observes the new target value and starts driving the interpolation.
Here is the state holder and the derived selection at the top of the composable:
var selectedIndex by remember { mutableStateOf(0) }
val (selected, selectedName) = PALETTE[selectedIndex.coerceIn(0, PALETTE.lastIndex)]
The coerceIn call is a small guard that keeps the index inside the palette bounds even if the list shrinks during a hot reload. With the selected color resolved, the rest of the composable feeds it into the two color animations.
animateColorAsState as a state interpolator
animateColorAsState is the color specific sibling of animateFloatAsState, animateDpAsState, and the rest of the animate*AsState family. It takes a target value, an animation spec, and a label, and returns a State<Color> that you read inside composition. Whenever the target changes, the function launches a coroutine on the recomposer that drives the value from the current animated color to the new target according to the spec.
The actual call from the source is short:
val animatedBg by animateColorAsState(
targetValue = selected,
animationSpec = tween(durationMillis = COLOR_TRANSITION_MS),
label = "bg",
)
Three things matter here. The targetValue is a snapshot read on selected, so any change to selectedIndex invalidates the composition that owns this state and a new target flows in. The animationSpec is a tween of COLOR_TRANSITION_MS milliseconds, which gives you a fixed duration interpolation with a default easing curve. The label is metadata for the Animation Inspector in Android Studio, useful when you have multiple color animations on the same screen.
Under the hood, Color is not a number. It is a packed value containing red, green, blue, and alpha components inside a particular ColorSpace. To animate between two colors, the runtime needs a way to break a Color apart into a vector of floats, interpolate each component, and rebuild a Color at the end. That bridge is a TwoWayConverter<Color, AnimationVector4D>, and Compose builds one based on the source and target color spaces.
The interpolation itself is a per channel lerp. For each component the runtime computes start + (end - start) * fraction, where fraction is what the tween spec produces over time. Because the conversion can route through a perceptual color space, the midpoint between two colors looks like a reasonable in between hue rather than a muddy gray you sometimes get from naive RGB interpolation.
The second animation in the example uses the same machinery for the foreground color:
val animatedContent by animateColorAsState(
targetValue = if (selected.luminance() > 0.5f) Color(0xFF202020) else Color.White,
animationSpec = tween(durationMillis = COLOR_TRANSITION_MS),
label = "content",
)
The target is computed from the selected color's luminance(). If the swatch is light, the text becomes a near black at 0xFF202020. If it is dark, the text becomes white. Because both color animations share the same duration, the background and the text reach their new values together, and the label never strands at a low contrast moment during the transition.
Designing the palette
The PALETTE is a List<Pair<Color, String>> declared at the top of the composable:
val PALETTE = listOf(
Color(0xFF63CCD9) to "Coral",
Color(0xFFC6FF00) to "Lime",
Color(0xFF40C4FF) to "Sky",
Color(0xFF47CD72) to "Lavender",
)
Each pair is a color and a display name. The hero label reads from selectedName, and the row of swatches uses both the color (for the circle) and the name (for the caption below it). The names do not have to match the hue. They are display strings, and the example deliberately keeps them generic so you can swap colors without renaming everything.
The four colors are chosen to span a wide range of hues. A teal cyan, a saturated yellow green, a bright sky blue, and a vivid green. They also span a range of luminance values, which is what makes the contrast switch on the foreground text actually visible. A palette of four near identical pastels would still animate, but you would not see the text color flip, and the demo would feel flat.
Because the entire palette is declared inline, hot reload picks up changes the moment you save the file. Adding a fifth entry, replacing a hex value, or renaming "Sky" to "Ocean" all reflow the row of swatches and the hero label without losing the current selection (the coerceIn guard absorbs any index that goes out of range).
Tweaking transition timing and the palette
COLOR_TRANSITION_MS is set to 600. That value flows into both tween specs, which means it controls the pace of the background morph and the foreground morph in lockstep. Reducing it to 200 makes the transition feel snappy, almost like a direct swap with a hint of motion. Increasing it to 1200 makes the morph feel deliberate, almost cinematic, and lets the eye notice the intermediate hues that color space interpolation produces.
There is no easing argument in the example, so the tween falls back to its default FastOutSlowInEasing. The curve starts quickly and settles slowly. For a color morph this reads as a confident commit followed by a gentle landing, which suits a swatch picker where the user expects feedback the instant they tap. If you wanted a more linear feel, you would pass easing = LinearEasing into the tween, and the color would drift across at a constant rate. For a more elastic feel, you would swap tween for spring and let the stiffness and damping ratio shape the curve instead.
Extending PALETTE is the other knob. Append a fifth or sixth Color(...) to "Name" pair and the row redistributes evenly because each swatch column uses Modifier.weight(1f). The hero, the swatch row, and the label all read from the same list, so a single edit propagates everywhere. If you want a themed showcase, swap all four entries for shades of one hue and the demo turns into a tonal browser. If you want a high contrast wheel, drop in colors that sit on opposite sides of the spectrum and the morph travels visibly through intermediate tones.
One more constant worth noting is HERO_HEIGHT_DP, set to 180. It is not part of the animation spec, but it controls how much surface area shows the morph. A taller hero makes the color change feel more dominant on screen. A shorter hero turns the demo into a strip of color and pushes the swatches into focus. Pair a shorter hero with a longer COLOR_TRANSITION_MS and the demo emphasizes the journey between colors. Pair a taller hero with a shorter duration and it emphasizes the destination.
Conclusion
In this article, you've explored a state driven color animation built on animateColorAsState, walked through how a single state change feeds two coordinated transitions, and looked at how Compose interpolates between colors using a per channel lerp routed through a color space aware converter. The palette and the transition duration are the only two real knobs in the example, and together they define both the look and the rhythm of the demo.
Understanding these internals helps you reason about color animation as a state problem rather than an animation problem. You do not start an animation when the user taps a swatch. You change the selected color, and the runtime observes the new target value and produces the in between frames for you. Choosing the spec is choosing the personality of that transition, and the choice of palette is choosing what the transition has to say.
Whether you're building a theme picker for a settings screen, a brand color showcase inside a marketing app, or a dynamic accent system that responds to album art or wallpaper, this pattern of pairing animateColorAsState with a curated palette gives you smooth, predictable color motion with almost no code. The hard work of color space conversion and per channel interpolation lives inside the runtime, and your job is to pick the targets and the timing.
As always, happy coding!
— Jaewoong (skydoves)