Build an Animated Dashboard with Spring Physics | Kotlin Desktop #1
Video: Build an Animated Dashboard with Spring Physics | Kotlin Desktop #1 by Taught by Celeste AI - AI Coding Coach
Animations and Spring Physics: Build an Animated Dashboard
Crossfade,animateColorAsState,animateIntAsState,animateDpAsState.rememberInfiniteTransitionfor shimmer and pulses. Spring physics for cards. The bridge from "static UI" to "feels alive."
Today's app is a small analytics dashboard. Two tabs (Overview / Analytics), three animated stat cards, two ring-progress widgets, an expandable details panel, a shimmering skeleton while data loads, and a pulsing live-status dot. All the animation pieces a real app needs, in one screen.
What we are building
A 900×600 window with:
- A tab bar — switching tabs cross-fades the content.
- Three stat cards that fade in and slide up one after another, with their numbers ticking up from 0.
- Two progress rings whose arcs animate from 0% to their target.
- A shimmer skeleton while data loads.
- A pulsing green status dot in the corner.
The animation toolbox
Compose has four families of animation APIs, and this lesson uses one of each:
animate*AsState— declarative state animations.animateColorAsState,animateFloatAsState,animateDpAsState,animateIntAsState. Good for "when this value changes, smoothly transition to the new value."AnimatedVisibility/AnimatedContent/Crossfade— animating presence and swaps.updateTransition— a transition driven by a state, with multiple animated children.rememberInfiniteTransition— looping animations (shimmer, pulse, breathing).
Crossfade for tab content
Crossfade(
targetState = selectedTab,
animationSpec = tween(500),
modifier = Modifier.weight(1f),
) { tab ->
if (isLoading) {
LoadingSkeleton()
} else {
val stats = if (tab == 0) overviewStats else analyticsStats
DashboardContent(stats, progress)
}
}
Crossfade(targetState) { state -> ... } renders content based on state. When state changes, the new content fades in while the old fades out — a 500ms tween here.
For more than just fade, swap to AnimatedContent, which lets you customise enter/exit transitions independently. Crossfade is the simple case.
animateColorAsState for tab highlights
val textColor by animateColorAsState(
targetValue = if (selectedTab == index) KotlinPurple else Color.Gray,
animationSpec = tween(300),
)
TextButton(onClick = { selectedTab = index }) {
Text(title, color = textColor)
}
animateColorAsState(targetValue, animationSpec) returns a State<Color> that smoothly transitions to targetValue. Use the by delegate to read it as a plain Color.
The pattern generalises:
animateFloatAsStatefor opacity, rotation, scale.animateDpAsStatefor sizes, paddings, elevations.animateIntAsStatefor integer counters (the stat-card numbers).animateColorAsStatefor colours.
Each takes a targetValue and an animationSpec. The state recomposes every frame during the animation.
animateIntAsState for ticking counters
var animateValue by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { animateValue = true }
val animatedValue by animateIntAsState(
targetValue = if (animateValue) stat.value else 0,
animationSpec = tween(durationMillis = 1000),
)
Text(text = "${stat.prefix}${animatedValue}${stat.suffix}")
The card mounts with animateValue = false, so targetValue = 0. A LaunchedEffect fires animateValue = true on the next frame, which flips the target to stat.value. The state animates from 0 to the real number over 1000ms — that's the counter ticking.
A small but high-impact effect for KPI-style numbers.
Spring physics
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = spring(dampingRatio = 0.6f, stiffness = 200f)) +
slideInVertically(
initialOffsetY = { it / 2 },
animationSpec = spring(dampingRatio = 0.6f, stiffness = 200f),
),
)
spring(dampingRatio, stiffness) gives motion that feels physical. Two parameters:
dampingRatio— how bouncy.1.0fis critically damped (no overshoot).0.6fovershoots noticeably.0.4fovershoots a lot.0.0fwould oscillate forever.stiffness— how fast. Higher = snappier.200fis moderately quick;1500fis very snappy.
Springs feel different from tween because they don't have a fixed duration — duration depends on the distance and the physics. They look natural for things that "pop into place."
tween(duration, easing) is the alternative — fixed duration, configurable easing curve. Use tween when timing needs to be predictable; spring when motion needs to feel alive.
Staggered card entry
val cardVisible = remember { mutableStateListOf(false, false, false) }
LaunchedEffect(stats) {
cardVisible.indices.forEach { cardVisible[it] = false }
for (i in cardVisible.indices) {
delay(300)
cardVisible[i] = true
}
}
A list of three Booleans tracks per-card visibility. The LaunchedEffect resets them all to false, then flips them to true one at a time with 300ms gaps. Each StatCard reads cardVisible[index] and uses it to drive its AnimatedVisibility — the cards appear in sequence, one cascade.
Re-keys on stats, so when the user switches tabs (and the stats list changes), the staggered reveal plays again.
ProgressRing with drawArc
val animatedProgress by animateFloatAsState(
targetValue = if (animate) item.progress else 0f,
animationSpec = tween(durationMillis = 1200),
)
Canvas(modifier = Modifier.size(80.dp)) {
val strokeWidth = 8.dp.toPx()
// background ring
drawArc(
color = Color.White.copy(alpha = 0.1f),
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
// ...
)
// progress ring
drawArc(
color = item.color,
startAngle = -90f,
sweepAngle = 360f * animatedProgress,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
// ...
)
}
Two drawArc calls — one for the faint background ring, one for the colored progress arc. startAngle = -90f starts at twelve o'clock; positive sweepAngle goes clockwise.
Multiplying 360f * animatedProgress makes the sweep grow as the animated value climbs from 0 to 1.
StrokeCap.Round rounds the arc ends — looks like a real progress ring.
Infinite transitions: shimmer
val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
val shimmerOffset by infiniteTransition.animateFloat(
initialValue = -300f,
targetValue = 300f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
)
val shimmerBrush = Brush.linearGradient(
colors = listOf(
Color.White.copy(alpha = 0.05f),
Color.White.copy(alpha = 0.15f),
Color.White.copy(alpha = 0.05f),
),
start = Offset(shimmerOffset, 0f),
end = Offset(shimmerOffset + 300f, 0f),
)
Box(modifier = Modifier.background(shimmerBrush))
rememberInfiniteTransition plus animateFloat with infiniteRepeatable gives a value that loops forever. Here the value is a horizontal offset; we plug it into a linear gradient that paints across the box, restarting every 1200ms.
RepeatMode.Restart jumps back to the start each cycle. RepeatMode.Reverse ping-pongs — useful for the pulsing dot.
The skeleton renders while loading (isLoading = true); when data arrives, Crossfade swaps it for the real content.
PulsingDot
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val scale by infiniteTransition.animateFloat(
initialValue = 0.8f,
targetValue = 1.2f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = EaseInOut),
repeatMode = RepeatMode.Reverse,
),
)
Box(
modifier = Modifier
.size(10.dp)
.graphicsLayer(scaleX = scale, scaleY = scale)
.clip(CircleShape)
.background(color),
)
graphicsLayer(scaleX, scaleY) applies a scale transform without affecting layout. The dot grows to 1.2× and shrinks back to 0.8× every 800ms — same loop as the breathing icon on a status indicator.
graphicsLayer is the right modifier for animated transforms (rotation, translation, alpha). It's faster than re-laying-out the parent because it operates on the rendered pixels.
ExpandableSection with updateTransition
var expanded by remember { mutableStateOf(false) }
val transition = updateTransition(targetState = expanded, label = "expand")
val arrowRotation by transition.animateFloat(label = "arrow") { state ->
if (state) 180f else 0f
}
Row(modifier = Modifier.clickable { expanded = !expanded }) {
Text("▼", modifier = Modifier.graphicsLayer(rotationZ = arrowRotation))
Text(title)
}
transition.AnimatedVisibility(
visible = { it },
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
content()
}
updateTransition(state) builds a transition object that drives multiple animations off the same state. We animate the arrow rotation (0° → 180°) and the body's expand/collapse off the same expanded flag.
The advantage over multiple animate*AsState calls: when expanded changes, all child animations start in sync from a single transition. For one or two values, individual animateAsState calls work. For three or more coordinated animations, updateTransition is cleaner.
Animation specs cheat sheet
tween(durationMillis, easing = LinearEasing | FastOutSlowInEasing | ...)— fixed-duration animation with a curve.spring(dampingRatio, stiffness)— physics-based, no fixed duration.snap(delayMillis)— instant jump after a delay.infiniteRepeatable(animation, repeatMode)— wraps a tween or spring to loop.keyframes { durationMillis = N; v1 at t1; v2 at t2 }— custom keyframe interpolation.
For most cases, tween for predictable UI motion and spring for things that "pop." Reach for keyframes only when you need precise multi-stop control.
Performance: when does it slow down?
Compose animations are cheap by default — they recompose only the leaves that read animated state. But you can drift into trouble:
- Animating a state that drives layout for many children. The whole parent recomposes every frame.
- Lots of independent infinite transitions. Each one ticks every frame; lots of them = lots of recomposition.
Modifier.graphicsLayeris your friend. Transforms applied viagraphicsLayerdon't trigger relayout, just redraw.
Compose's animation framework is one of the most efficient in any UI toolkit — but you can still over-animate if every leaf is breathing.
Common mistakes
Forgetting LaunchedEffect to kick off entry animations. A card with mutableStateOf(false) stays invisible forever without something flipping it true. Either drive visibility from a parent (the staggered reveal pattern) or use LaunchedEffect(Unit) { visible = true }.
Using animateAsState for things that should be infinite. animateFloatAsState runs once per target change. Loops need rememberInfiniteTransition.
Reading animated state outside a Composable. The animated value is State<T> — only Composables can read it reactively. Outside a composition, you'd read a stale snapshot.
Animating layout-affecting properties. Animating padding or width on a high-up parent forces children to relayout every frame. Animate visual transforms via graphicsLayer instead.
Setting animationSpec = tween(99999) to "see the animation better." Long durations feel sluggish in real use. Tune at production speeds (200–500ms for snappy UI) and trust the framework.
What's next
Lesson 13: multi-window apps and chat. Run multiple Window { ... } blocks from the same application, share state across windows, and build a small chat app with two windows that talk to each other.
Recap
Crossfade for content swaps. animateColorAsState / animateFloatAsState / animateIntAsState / animateDpAsState for state-driven transitions. AnimatedVisibility with spring() for entry animations. rememberInfiniteTransition for shimmer and pulse loops. Canvas { drawArc(...) } plus an animated float for progress rings. updateTransition for multi-property coordinated animations. graphicsLayer for transform-only animations.
Next lesson: multi-window chat app.