Animated Counter
A number counter that slides and fades between values using AnimatedContent. Direction follows whether the count went up or down.
AnimatedContent is the swap with motion primitive in Compose. When the target state changes, it keeps the outgoing content alive long enough to animate it out, composes the new content, and animates it in. The choreography of how the old leaves and the new arrives lives entirely in a transitionSpec lambda, which returns a ContentTransform. This makes AnimatedContent a small surface area with a wide expressive range, because every visual decision is a function of the state pair.
In this article, you'll explore a number counter that slides and fades between values using AnimatedContent, how the transitionSpec reads direction from targetState and initialState, how togetherWith composes enter and exit animations, and how SizeTransform handles container resizing when digit width changes.
How the example is structured
The example holds a single integer state and exposes two buttons that decrement and increment it. The number itself is rendered through AnimatedContent, which treats the integer as the content key. Whenever the key changes, the previous number animates out while the next number animates in.
var count by remember { mutableIntStateOf(0) }
Box(
modifier = Modifier.fillMaxWidth().height(160.dp),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = count,
transitionSpec = { /* direction aware spec */ },
label = "counter",
) { value ->
Text(text = "$value", fontSize = 96.sp, fontWeight = FontWeight.Bold)
}
}
The fixed height Box gives the animated number a stable runway so the slide motion has somewhere to travel. The content lambda receives value, which is the resolved target for that particular composition. Compose may call this lambda for both the outgoing and incoming numbers at the same time, since both must be on screen during the transition.
Three tweakable constants drive the feel of the animation:
val slideDurationMs = 650
val fadeDurationMs = 450
val slideOffsetDivisor = 1
slideDurationMs controls how long the slide takes, fadeDurationMs controls the crossfade, and slideOffsetDivisor decides how far each number travels relative to its own height.
ContentTransform: enter and exit choreography
The transitionSpec is a lambda with AnimatedContentTransitionScope<Int> as its receiver. Inside it, you build a ContentTransform by combining one EnterTransition and one ExitTransition with the togetherWith infix function. Each side may itself be a sum of smaller transitions joined with +, which is how the example fuses sliding and fading into a single coordinated motion.
Looking at the upward branch of the spec:
slideInVertically(
animationSpec = tween(slideDurationMs),
initialOffsetY = { it / slideOffsetDivisor },
) + fadeIn(animationSpec = tween(fadeDurationMs)) togetherWith
slideOutVertically(
animationSpec = tween(slideDurationMs),
targetOffsetY = { -it / slideOffsetDivisor },
) + fadeOut(animationSpec = tween(fadeDurationMs))
The new number starts below its final position by it / slideOffsetDivisor, where it is the measured height of the content in pixels. It slides up into place while fading in. At the same time, the old number slides further up by the negative of that offset and fades out. Because both halves share the same slideDurationMs and fadeDurationMs, the swap reads as one unified motion rather than two independent animations.
The whole ContentTransform can then be tagged with a SizeTransform using the using infix:
slideIn + fadeIn togetherWith slideOut + fadeOut using
SizeTransform { initialSize, targetSize -> tween(300) }
SizeTransform describes how the container box itself should resize when the new content has different measured dimensions. Without it, the container snaps to the new size on the first frame, which can clip the outgoing content.
Reading the direction from state
Inside the transitionSpec, you can compare targetState and initialState to pick a direction aware spec. The example does exactly this:
val goingUp = targetState > initialState
if (goingUp) {
// new slides up from below, old slides up and out the top
} else {
// new slides down from above, old slides down and out the bottom
}
When the count increases, the new digit enters from below and the old one exits upward, which matches the mental model of numbers climbing. When the count decreases, both motions reverse so the digits feel like they are falling. This direction check works because AnimatedContent exposes both the previous and next values inside the spec, letting the transition behave like a function of the transition itself rather than a fixed animation.
The integer that you pass as targetState doubles as the content key. AnimatedContent runs a transition only when this key changes by equality, which is why incrementing the count by one triggers the swap but composing the parent for unrelated reasons does not. If you wanted to animate based on a richer object while keying on something simpler, you would pass a contentKey lambda alongside targetState.
The using builder is how the spec attaches optional configuration like SizeTransform without changing the shape of the ContentTransform. It returns a new ContentTransform with the size animation set, so you can chain it onto either branch of the if independently.
Tweaking the spec
Each tweakable constant maps to a specific perceptual quality of the animation, so you can tune them in isolation.
slideOffsetDivisor controls how far the swap travels. With a value of 1, both numbers move a full content height, which gives a strong sense of replacement, almost like a slot machine reel. Set it to 2 and each number travels half its height, which feels lighter. A value of 4 produces a subtle drift that pairs well with shorter durations and longer fades, because the slide is barely visible and the crossfade carries most of the visual change.
slideDurationMs and fadeDurationMs control snappiness. The example uses 650 and 450 respectively, which is on the slower, more deliberate end. Drop the slide to 120 and the counter feels reactive, suitable for high frequency interactions like a stepper in a form. Push it to 800 and the swap becomes a small ceremony, which fits hero numbers like a score reveal. Setting fadeDurationMs to 0 removes the crossfade entirely, leaving a pure slide that is sharper but can feel abrupt when digit shapes differ.
SizeTransform controls how the container width animates when the digit count changes, for example when the counter goes from 9 to 10. The default tween easing gives a smooth curve, but you can pass a keyframes spec or a different Easing to make the resize feel bouncier or more linear. If you omit SizeTransform, the container snaps to the new measurement on the first frame of the transition, which often causes the outgoing digit to clip on one side as it slides away. Adding it costs almost nothing and removes a class of visual artifacts that are hard to debug after the fact.
A second useful axis is the animationSpec itself. The example uses tween, which interpolates linearly through a curve. Swapping in spring for the slide gives a small overshoot at the end of each transition, which can make the counter feel more physical without changing the offsets. Mixing specs is fine: a tween on the fade with a spring on the slide keeps the crossfade predictable while letting the motion breathe.
Conclusion
In this article, you've explored how AnimatedContent swaps between integer states with a coordinated slide and fade, how the transitionSpec lambda reads targetState and initialState to pick a direction aware ContentTransform, and how togetherWith, using, and SizeTransform compose into a single declarative motion description.
Understanding these internals helps you choose the right primitive for content that changes identity rather than properties. AnimatedContent is built around the idea that the old and new content coexist briefly so they can choreograph their handoff. Once you internalize that, deciding between AnimatedContent, Crossfade, and animate*AsState becomes a question of whether the change is a swap, a blend, or a value tween.
Whether you are building a score display, a stepper, a tab indicator, or any UI where one piece of content replaces another, this pattern provides the foundation. Start with sensible durations, pick an offset divisor that matches the weight of the motion you want, and reach for SizeTransform whenever the new content might measure differently from the old.
As always, happy coding!
— Jaewoong (skydoves)