Custom Loading Spinner

A loading indicator built from infinite rotation plus a color sweep. Two infinite transitions layered on the same graphicsLayer.

Compose APIs
rememberInfiniteTransitiongraphicsLayerkeyframes
Try tweaking
rotation durationcolor listsweep keyframes

Compose ships with CircularProgressIndicator, and for most screens that one line is enough. But the moment you want a custom stroke width, a custom color, an unusual sweep arc, or a brand-aligned motion curve, the built-in component starts to feel like a black box. Building your own spinner from a Canvas plus a couple of infinite transitions only takes a few dozen lines, and once you have done it you understand exactly which knobs control speed, which control shape, and which control color. That understanding pays off every time you need a non-standard loading affordance.

In this article, you'll explore how AnimationExample8 composes two infinite animations into a single spinner, how rememberInfiniteTransition plus animateFloat drive both rotation and arc sweep, why drawing through Canvas and rotate { drawArc(...) } keeps the cost in the draw phase, and how to tune duration, colors, and sweep range to reshape the motion.

How the example is structured

The spinner is a single Canvas of fixed size that draws one stroked arc. The arc itself never changes shape inside the draw call. What changes is the rotation applied to the canvas before the arc is drawn, and the sweepAngle passed into drawArc. Two independent animations feed those two values, and that is where the visual rhythm comes from.

The composable declares the constants up front, then sets up an InfiniteTransition, then draws the arc inside a rotate scope:

val ROTATION_DURATION_MS = 1400
val SWEEP_DURATION_MS = 1200
val SPINNER_COLOR = Color(0xFF1A94D2)
val SPINNER_STROKE_DP = 15.dp
val SPINNER_SIZE_DP = 163.dp
val SWEEP_MIN = 10f
val SWEEP_MAX = 290f

These are the only values you tweak to reshape the spinner. The full rotation cycle takes 1400ms, the sweep oscillation takes 1200ms, and the arc itself is a 15dp stroked ring that lives inside a 163dp box. The two durations are deliberately different. That mismatch is why the spinner never appears to repeat the same frame twice, even though both animations loop forever.

Two infinite transitions on one element

A single rememberInfiniteTransition hosts both animations. InfiniteTransition is a container that drives any number of child animations off the same composition-aware clock, and each child runs on its own schedule. You request a child animation by calling transition.animateFloat(...) once per value you want to drive.

val transition = rememberInfiniteTransition(label = "spinner")

val rotation by key(ROTATION_DURATION_MS) {
  transition.animateFloat(
    initialValue = 0f,
    targetValue = 360f,
    animationSpec = infiniteRepeatable(
      animation = tween(durationMillis = ROTATION_DURATION_MS, easing = LinearEasing),
      repeatMode = RepeatMode.Restart,
    ),
    label = "rotation",
  )
}

rotation ramps from 0f to 360f linearly, then restarts. RepeatMode.Restart is what you want for a wheel. The angle jumps back to zero at the end of each cycle, but visually 360° and 0° are the same orientation, so you see a continuous spin.

The sweep animation runs against the same transition object:

val sweep by key(SWEEP_DURATION_MS) {
  transition.animateFloat(
    initialValue = SWEEP_MIN,
    targetValue = SWEEP_MAX,
    animationSpec = infiniteRepeatable(
      animation = tween(durationMillis = SWEEP_DURATION_MS, easing = LinearEasing),
      repeatMode = RepeatMode.Reverse,
    ),
    label = "sweep",
  )
}

Here RepeatMode.Reverse swings the value back from 290f to 10f instead of snapping. That gives you the breathing arc shape. Each child animation reads the frame clock through withFrameNanos under the hood, so stacking two or twenty of them on the same InfiniteTransition is cheap. They share the same composition subscription and produce a separate State<Float> each.

The key(ROTATION_DURATION_MS) { ... } wrapper is a workaround worth knowing. InfiniteTransition.animateFloat only re-reads initialValue and targetValue on recomposition. It ignores animationSpec changes once the animation has started. Wrapping the call in key() forces Compose to discard the previous animation state and start a fresh one whenever the duration constant changes, which is what you want for live tweaking inside a hot-reloadable preview.

graphicsLayer for cheap rotation

The example reaches for androidx.compose.ui.graphics.drawscope.rotate rather than Modifier.graphicsLayer { rotationZ = ... }. The two paths achieve the same visual outcome, and the underlying performance story is the same: only the draw phase reruns.

Canvas(modifier = Modifier.size(SPINNER_SIZE_DP)) {
  val strokePx = SPINNER_STROKE_DP.toPx()
  val inset = strokePx / 2f
  val arcSize = Size(size.width - strokePx, size.height - strokePx)
  rotate(degrees = rotation) {
    drawArc(
      color = SPINNER_COLOR,
      startAngle = 0f,
      sweepAngle = sweep,
      useCenter = false,
      topLeft = Offset(inset, inset),
      size = arcSize,
      style = Stroke(width = strokePx, cap = StrokeCap.Round),
    )
  }
}

rotate inside DrawScope pushes a transformation matrix onto the canvas, draws, then pops it. Nothing in the layout tree resizes, nothing remeasures, and Compose does not even invalidate the parent. The Canvas reads the two State<Float> values inside its draw lambda, so the snapshot system invalidates only that single draw call when either value changes.

If you wanted to rotate a full subtree of composables instead of contents inside a single canvas, you would use Modifier.graphicsLayer { rotationZ = rotation }. The same rule applies: setting rotationZ through graphicsLayer triggers only the draw phase, because the layer composites pixels with the rotation applied at the GPU level. That property is why you should always reach for graphicsLayer for continuous transforms instead of, say, recomposing a Modifier.rotate() (which boxes through layout).

The Stroke(width = strokePx, cap = StrokeCap.Round) configuration draws the arc as an outlined ring with rounded ends, and the inset = strokePx / 2f math centers the stroke on the boundary of the canvas so the rounded caps are not clipped.

tween and Reverse instead of keyframes

This example expresses the sweep with a plain tween plus RepeatMode.Reverse rather than a keyframes block. The result is the same idea you would express with keyframes: pin a value at one timestamp, pin another value at a later timestamp, and let Compose interpolate between them. With Reverse you only describe the two endpoints (SWEEP_MIN = 10f and SWEEP_MAX = 290f) and a single duration, and Compose plays the curve forward and backward forever.

If you wanted a more deliberate shape, for example a long pause at the small sweep, a fast expansion, then a slow collapse, you would replace the tween(...) argument inside infiniteRepeatable with a keyframes { ... } block:

animation = keyframes {
  durationMillis = SWEEP_DURATION_MS
  SWEEP_MIN at 0
  SWEEP_MIN at 200
  SWEEP_MAX at 700
  SWEEP_MIN at SWEEP_DURATION_MS
}

Each value at timestamp line pins the animated float to a specific value at a specific point in the cycle, and Compose linearly interpolates between consecutive pins unless you attach an easing per segment. Keyframes give you per-segment control, which is what you reach for when an arc needs to feel like Material 3's CircularProgressIndicator, where the head and tail of the sweep race ahead of and behind each other.

Tweaking duration, colors, sweep range

Each constant in the example maps to a single perceptual property of the spinner.

  • ROTATION_DURATION_MS: lower values spin faster. At 600ms the spinner reads as urgent and a little frantic. At 2400ms it reads as patient. Stay between 1000ms and 1800ms for a neutral progress feel.
  • SWEEP_DURATION_MS: controls how fast the arc breathes. Picking a duration that does not divide evenly into the rotation duration is what keeps the motion from looking mechanical. The example pairs 1400ms with 1200ms, and the two cycles drift in and out of phase indefinitely.
  • SPINNER_COLOR: a single Color. To get a gradient sweep, swap drawArc(color = ...) for drawArc(brush = Brush.sweepGradient(listOf(...))) and animate a hue offset by adding a third animateFloat to the same transition.
  • SWEEP_MIN / SWEEP_MAX: define the visible arc length. Setting SWEEP_MIN = 0f makes the arc fully disappear at one end of the cycle, which produces a pulsing dot effect. Setting SWEEP_MAX = 360f makes the spinner briefly form a complete ring.
  • SPINNER_STROKE_DP and SPINNER_SIZE_DP: control thickness and overall footprint. The stroke value also feeds the inset math, so the arc stays centered no matter how thick the stroke gets.

If you want to swap the tween for a keyframes block as shown above, edit only the animationSpec field. The key(SWEEP_DURATION_MS) wrapper guarantees the new spec actually takes effect on the next composition, instead of being silently ignored.

Conclusion

In this article, you've explored how a custom loading spinner falls out of two pieces: an arc drawn inside a Canvas, and two animateFloat values from a single rememberInfiniteTransition driving its rotation and sweep. The rotation runs as a linear tween with RepeatMode.Restart, the sweep runs as a linear tween with RepeatMode.Reverse, and the mismatched durations keep the motion from ever looking exactly like itself.

Understanding this internal layout matters because it tells you which knob to turn when a designer asks for "a little faster" or "less aggressive." Speed lives in the rotation duration. Personality lives in the sweep range and the relationship between the two durations. Rendering cost stays low because rotation through DrawScope.rotate (or Modifier.graphicsLayer) only re-runs the draw phase, never measure or layout. The snapshot system invalidates only the canvas's draw lambda when either State<Float> changes.

Whether you're building a brand-aligned splash screen, a custom indicator inside a chat input, or a debug HUD for an internal tool, the pattern is the same: pick a shape you can draw in DrawScope, parameterize the few values that should move, drive them with InfiniteTransition.animateFloat, and tune from there. Once the building blocks click, you stop reaching for CircularProgressIndicator by default and start designing motion that fits the screen it lives on.

As always, happy coding!

Jaewoong (skydoves)