Michael Evans

A bunch of technobabble.

Smooth Theme Transitions in Compose With Animated ColorSchemes

| Comments

If your Jetpack Compose app supports light and dark themes, you’ve probably noticed the default behavior: a sudden cut between color schemes when the system UI mode changes.

I think we’ve all seen theme changes that look like this:

We can do better.

By wrapping your theme setup with some animation, we can achieve smooth transitions between light and dark themes that feel much more polished — without rebuilding your entire app structure.

The Idea: Animate the ColorScheme

Jetpack Compose’s MaterialTheme accepts a ColorScheme. If we gradually animate the colors inside that scheme when the system toggles between dark and light, we can fade the colors smoothly across the entire app.

Here’s a simplified example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Composable
fun AnimatedTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val targetScheme = if (darkTheme) DarkColorScheme else LightColorScheme

    val animatedScheme = targetScheme.copy(
        primary = animateColorAsState(targetScheme.primary).value,
        background = animateColorAsState(targetScheme.background).value,
        surface = animateColorAsState(targetScheme.surface).value,
        onPrimary = animateColorAsState(targetScheme.onPrimary).value,
        // Add more swatches as needed...
    )

    MaterialTheme(
        colorScheme = animatedScheme,
        typography = Typography,
        shapes = Shapes,
        content = content
    )
}

You can expand this to include all 20+ color swatches if you want — but we’ll show a better way shortly.


The Problem: Too Many Animations

Manually animating every color swatch with animateColorAsState works, but:

  • It’s verbose
  • It runs a lot of recompositions (even for unused swatches)
  • It can be hard to keep up if ColorScheme changes

A Better Approach: Animating with updateTransition

A cleaner solution is to animate all properties as part of a single Transition. That way, Compose shares the animation clock and easing, and you’re not scattering a dozen independent animation calls.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private fun defaultColorTransitionSpec(): FiniteAnimationSpec<Color> =
    tween(durationMillis = 500, easing = LinearOutSlowInEasing)

@Composable
fun AnimatedTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val targetScheme = if (darkTheme) DarkColorScheme else LightColorScheme
    val transition = updateTransition(targetScheme, label = "themeTransition")

    val primary by transition.animateColor(
        label = "primary",
        transitionSpec = { defaultColorTransitionSpec() }) { it.primary }
    val background by transition.animateColor(
        label = "background",
        transitionSpec = { defaultColorTransitionSpec() }) { it.background }
    val surface by transition.animateColor(
        label = "surface",
        transitionSpec = { defaultColorTransitionSpec() }) { it.surface }
    val onPrimary by transition.animateColor(
        label = "onPrimary",
        transitionSpec = { defaultColorTransitionSpec() }) { it.onPrimary }

    val animatedScheme = targetScheme.copy(
        primary = primary,
        background = background,
        surface = surface,
        onPrimary = onPrimary
    )
    MaterialTheme(colorScheme = animatedScheme, content = content)
}

This version:

  • Uses a single Transition to group related animations
  • Shares the same easing/duration across all swatches
  • Is easier to extend or modify incrementally

After we add our animations, it will look more like this:


Final Notes

  • If you want to animate all color properties, you’ll still need to call animateColor for each one — but this pattern scales better than copy-pasting 25 animateColorAsState calls.
  • For most apps, animating just a handful of swatches (primary, background, surface, onPrimary, error) gives most of the perceived polish.
  • Avoid using reflection or deep copying here — it’s better to be explicit and in control of what’s animated.

Wrap-Up

This technique brings a much smoother feel to your app, especially if your users frequently switch between light and dark mode (or you support a manual toggle).

(Hopefully the Compose team eventually provides a helper for this boilerplate.)

Happy theming!

Sample Code

Want to try it out yourself? I’ve published a small sample project on GitHub that demonstrates both animated and non-animated theme switching:

View the sample project on GitHub

It includes the exact code from this post and the toggle UI shown in the GIFs.

Comments