Jetpack Compose Progress Indicator

When building Android apps, giving users clear visual feedback during loading or processing operations is one of those small details that makes a big difference. The Jetpack Compose progress indicator is built exactly for this — it ships as a first-class Material component, works declaratively like everything else in Compose, and requires almost no boilerplate to get going. Whether you need a spinning circle while waiting on a network call or a filling bar tied to a real upload percentage, Compose has both covered out of the box. This guide walks through both composables, both modes, customization, and animated state-driven usage.

Two Modes Every Jetpack Compose Progress Indicator Supports

Before diving into code, it is worth understanding the two fundamental operating modes available for any Compose loading indicator.

Indeterminate mode means the duration of the operation is unknown. The animation loops infinitely, signaling that something is happening without committing to a timeline. You see this when an app is connecting to a server, fetching initial data, or initializing in the background.

Determinate mode means you know exactly how far along an operation is. The progress value is a Float between 0.0, meaning not started, and 1.0, meaning complete. File uploads, multi-step form flows, and download managers are classic examples where a determinate Android progress bar communicates real information rather than just "something is happening."

Compose exposes both modes through two composables: CircularProgressIndicator and LinearProgressIndicator. You switch between modes simply by whether or not you pass a progress value.

CircularProgressIndicator — Indeterminate Loading

The CircularProgressIndicator composable in its simplest form runs in indeterminate mode by default. Drop it into your composable tree and it immediately starts spinning. This is the most common pattern you will encounter in Android apps built with Jetpack Compose.

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun IndeterminateCircularDemo() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CircularProgressIndicator()
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewIndeterminateCircular() {
    IndeterminateCircularDemo()
}

When you run this, a circular ring animates continuously in the center of the screen. There is no completion point — it spins until you remove it from the composition. This is exactly what you want while awaiting an API response before swapping the loader with real content. The Material3 version of CircularProgressIndicator follows your app's color scheme automatically, using the primary color token from your theme without any extra configuration.

Determinate CircularProgressIndicator with Compose

When you know the progress value, you pass a Float to the progress parameter. The composable then renders a filled arc proportional to that value, giving users a concrete sense of how much longer they need to wait. This is where the Jetpack Compose progress indicator becomes genuinely useful for real-world scenarios.

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun DeterminateCircularDemo() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CircularProgressIndicator(progress = { 0.65f })
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewDeterminateCircular() {
    DeterminateCircularDemo()
}

Here the progress lambda returns 0.65f, which means the arc fills 65% of the circle. The arc starts at the top and sweeps clockwise. Unlike the indeterminate version, this is completely static unless you update the value through state. You will notice that in Material3 the progress parameter is a lambda rather than a plain Float — this is a Compose optimization that avoids unnecessary recomposition when only the progress value changes and not the rest of the composable's structure.

LinearProgressIndicator in Compose — Indeterminate Mode

The LinearProgressIndicator gives you a horizontal bar instead of a circle. It is often placed at the top of a screen or beneath a toolbar to indicate that something is loading in the background without blocking the rest of the UI. In indeterminate mode it animates a sliding highlight from left to right across the bar.

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun IndeterminateLinearDemo() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewIndeterminateLinear() {
    IndeterminateLinearDemo()
}

The Modifier.fillMaxWidth() stretches the bar edge to edge. Without it, the LinearProgressIndicator uses a default width that may look too short depending on your layout. The animation plays automatically and loops indefinitely, just like the circular variant in indeterminate mode. This makes it a great status strip beneath an app bar when pulling to refresh or syncing content in the background.

Determinate LinearProgressIndicator Compose

Passing a progress value to LinearProgressIndicator fills it proportionally from left to right. This is the classic Android progress bar pattern that most users already understand intuitively from traditional View-based apps, and Compose makes it just as simple to implement.

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun DeterminateLinearDemo() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        LinearProgressIndicator(
            progress = { 0.40f },
            modifier = Modifier.fillMaxWidth()
        )
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewDeterminateLinear() {
    DeterminateLinearDemo()
}

With progress set to 0.40f, exactly 40% of the bar is filled. The filled region uses the primary color from your MaterialTheme and the unfilled track uses a tinted variant of that color for contrast. Visually this gives a clear at-a-glance reading of how far along an operation is — no label needed for users to understand at 40%, there is more to go.

Customizing Colors and Stroke Width

The default styling follows Material3 design tokens, but both composables expose parameters for colors and dimensions. This lets you match the indicator to your app's visual identity without wrestling with a custom Canvas implementation.

For CircularProgressIndicator, you can adjust the color, track color, and stroke width independently:

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun StyledCircularIndicator() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CircularProgressIndicator(
            progress = { 0.75f },
            color = Color(0xFF6200EE),
            trackColor = Color(0xFFE0D7F5),
            strokeWidth = 8.dp
        )
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewStyledCircular() {
    StyledCircularIndicator()
}

The color parameter controls the filled arc, trackColor controls the background ring, and strokeWidth controls the ring's thickness. Bumping strokeWidth from the default 4.dp to 8.dp makes the indicator bolder and more visible — great for splash screens or prominent loading states where the indicator deserves more visual weight.

LinearProgressIndicator supports similar customization, and you can also control the bar's height through a modifier:

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun StyledLinearIndicator() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        LinearProgressIndicator(
            progress = { 0.55f },
            modifier = Modifier
                .fillMaxWidth()
                .height(10.dp),
            color = Color(0xFF03DAC5),
            trackColor = Color(0xFFB2EFE8)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewStyledLinear() {
    StyledLinearIndicator()
}

The height modifier pushes the bar taller than its default 4.dp. At 10.dp it becomes a prominent UI element instead of a subtle status strip. The teal color pair here gives strong contrast on both light and dark backgrounds — a useful combination when you want the Compose loading indicator to stand out on a light surface.

Animating Progress with animateFloatAsState

Static progress values are not useful in real apps. What you actually need is the indicator reacting to changing state — when a download proceeds from 0% to 100%, the UI should update smoothly rather than jumping between values. Compose handles this elegantly by combining state variables with animation APIs.

The animateFloatAsState function smoothly interpolates between old and new Float values whenever the underlying state changes. Combined with a determinate Jetpack Compose progress indicator, this creates a fluid animated bar or arc that tracks real progress gracefully without any manual animation logic.

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun AnimatedProgressDemo() {
    var targetProgress by remember { mutableFloatStateOf(0f) }
    val animatedProgress by animateFloatAsState(
        targetValue = targetProgress,
        label = "progress animation"
    )

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        LinearProgressIndicator(
            progress = { animatedProgress },
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(modifier = Modifier.height(24.dp))
        Button(onClick = {
            if (targetProgress < 1f) targetProgress = (targetProgress + 0.2f).coerceAtMost(1f)
        }) {
            Text("Advance Progress")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewAnimatedProgress() {
    AnimatedProgressDemo()
}

Each time the button is tapped, targetProgress increases by 0.2. The animateFloatAsState lambda watches for changes and smoothly interpolates the displayed value to the new target using a default spring animation. The progress lambda on LinearProgressIndicator reads animatedProgress, so the bar fills smoothly rather than jumping. When targetProgress reaches 1.0 the bar is completely filled. This is the exact pattern you would use when tying a Jetpack Compose progress indicator to a real ViewModel state derived from a download, upload, or multi-step process.

Full Working Example — Progress Indicator Showcase with Toggle

This example brings together everything: toggling between indeterminate and determinate modes, animated state-driven progress using animateFloatAsState, both CircularProgressIndicator and LinearProgressIndicator side by side, and a reset button. Run it directly as a standalone composable in Android Studio.

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Composable
fun ProgressIndicatorShowcase() {
    var isIndeterminate by remember { mutableStateOf(true) }
    var rawProgress by remember { mutableFloatStateOf(0f) }
    val animatedProgress by animateFloatAsState(
        targetValue = rawProgress,
        label = "showcase progress"
    )

    Surface(
        modifier = Modifier.fillMaxSize(),
        color = MaterialTheme.colorScheme.background
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(24.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = if (isIndeterminate) "Indeterminate Mode" else "Determinate Mode",
                style = MaterialTheme.typography.titleMedium
            )

            Spacer(modifier = Modifier.height(32.dp))

            if (isIndeterminate) {
                CircularProgressIndicator()
                Spacer(modifier = Modifier.height(16.dp))
                LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
            } else {
                CircularProgressIndicator(progress = { animatedProgress })
                Spacer(modifier = Modifier.height(16.dp))
                LinearProgressIndicator(
                    progress = { animatedProgress },
                    modifier = Modifier.fillMaxWidth()
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(text = "Progress: ${(animatedProgress * 100).toInt()}%")
            }

            Spacer(modifier = Modifier.height(32.dp))

            OutlinedButton(onClick = {
                isIndeterminate = !isIndeterminate
                rawProgress = 0f
            }) {
                Text(if (isIndeterminate) "Switch to Determinate" else "Switch to Indeterminate")
            }

            if (!isIndeterminate) {
                Spacer(modifier = Modifier.height(12.dp))
                Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                    Button(onClick = {
                        rawProgress = (rawProgress + 0.25f).coerceAtMost(1f)
                    }) {
                        Text("+25%")
                    }
                    OutlinedButton(onClick = { rawProgress = 0f }) {
                        Text("Reset")
                    }
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewProgressShowcase() {
    ProgressIndicatorShowcase()
}