Back to Blog

Build an Animated Dashboard with Spring Physics | Kotlin Desktop #1

Sandy LaneSandy Lane

Video: Build an Animated Dashboard with Spring Physics | Kotlin Desktop #1 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Animations and Spring Physics: Build an Animated Dashboard

Crossfade, animateColorAsState, animateIntAsState, animateDpAsState. rememberInfiniteTransition for 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:

  • animateFloatAsState for opacity, rotation, scale.
  • animateDpAsState for sizes, paddings, elevations.
  • animateIntAsState for integer counters (the stat-card numbers).
  • animateColorAsState for 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.0f is critically damped (no overshoot). 0.6f overshoots noticeably. 0.4f overshoots a lot. 0.0f would oscillate forever.
  • stiffness — how fast. Higher = snappier. 200f is moderately quick; 1500f is 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.graphicsLayer is your friend. Transforms applied via graphicsLayer don'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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.