Jetpack Compose Steppers: Create Beautiful Vertical & Horizontal Progress Indicators
Jetpack Compose has completely changed the way we build Android UIs — declarative, fast, and customizable.
But when it comes to progress tracking or multi-step workflows (like onboarding or checkout screens), steppers can make your app look more polished and professional.
In this article, we’ll explore three reusable Jetpack Compose Stepper components:
🧭 VerticalStepper – timeline-style layout with optional images
➡️ HorizontalStepper – clean and modern linear layout
⚡ CompactHorizontalStepper – minimal, icon-only version for mobile UIs
We’ll also look at how each part of the code works, why it’s written this way, and how you can customize it for your own projects.
🎯 What is a Stepper?
A Stepper visually represents progress through a sequence of steps.
It’s often used in:
Each step typically shows a title, an icon or number, and a connector line that indicates progress.
🧩 Step 1: Defining the Data Model
We start with a StepItem data class that describes each step.
data class StepItem(
val id: Int,
val title: String,
val subtitle: String? = null,
val image: Painter? = null,
val icon: ImageVector? = null,
val completed: Boolean = false
)
Each step can have:
- title and optional subtitle
- either an image (like a user profile picture) or an icon
- completed flag to style finished steps differently
This structure gives flexibility to support multiple visual styles without complex conditions.
🧱 Step 2: Creating the Vertical Stepper
The VerticalStepper displays steps stacked vertically — similar to a timeline.
@Composable
fun VerticalStepper(
steps: List,
modifier: Modifier = Modifier,
nodeSize: Dp = 56.dp,
lineWidth: Dp = 2.dp,
nodeContentPadding: Dp = 8.dp,
accent: Color = MaterialTheme.colorScheme.primary,
onStepClick: (StepItem) -> Unit = {}
) { ... }
🧠 How it works
- A Column arranges steps vertically.
- Each step has a circle node and a connector line (drawn using Canvas).
- If the previous step is completed, the line and node turn into the accent color (e.g., blue or green).
- Each node can display:
- an image (Painter)
- an icon (ImageVector)
- or fallback text (step number)
Why use Canvas?
It gives pixel-level control to draw the connecting lines and lets you easily animate or customize colors later.
✨ Example Preview
The preview shows how steps render in a vertical timeline.
VerticalStepper(
steps = demoSteps,
modifier = Modifier.padding(vertical = 8.dp)
)
➡️ Step 3: Building the Horizontal Stepper
Now let’s go horizontal!
This version is perfect for forms, checkout processes, or tab-like navigation flows.
@Composable
fun HorizontalStepper(
steps: List,
modifier: Modifier = Modifier,
nodeSize: Dp = 56.dp,
lineHeight: Dp = 2.dp,
accent: Color = MaterialTheme.colorScheme.primary,
onStepClick: (StepItem) -> Unit = {}
) { ... }
🔍 What’s happening inside
- Uses a Row layout to arrange steps side-by-side.
- Each step contains a circular node (with image/icon/text) and a small title below.
- Between each node, a connector line is drawn using a Box with height lineHeight.
- Completed steps show accent color; upcoming steps remain gray.
You can easily extend this for animations — for example, animating the connector width when a step is marked complete.
⚡ Step 4: CompactHorizontalStepper — Minimal and Mobile-Friendly
Sometimes you need a cleaner, space-saving stepper for compact UIs (like mobile or watch screens). That’s where CompactHorizontalStepper shines.@Composable
fun CompactHorizontalStepper(
steps: List,
modifier: Modifier = Modifier,
nodeSize: Dp = 40.dp,
accent: Color = MaterialTheme.colorScheme.primary,
onStepClick: (StepItem) -> Unit = {}
) { ... }
This version: - Displays only icons or step numbers
- Keeps connectors slim and subtle
- Uses smaller nodes and spacing
- Perfect for mobile step indicators (e.g., “Step 1 → Step 2 → Step 3”)
🧠 Key Composable Concepts Used
Concept | Purpose |
---|---|
Canvas | Draws custom connectors between nodes |
Surface | Adds elevation & shape (for circular nodes) |
Modifier.clickable | Makes each node interactive |
Box / Row / Column | Layout containers |
MaterialTheme.colorScheme | Uses dynamic colors (light/dark mode support) |
🖼️ Step 5: Preview and Demo
@Preview(showBackground = true)
@Composable
fun PreviewSteppers() {
val demoSteps = listOf(
StepItem(1, "Start", "Create account", completed = true),
StepItem(2, "Verify", "Email verification", completed = true),
StepItem(3, "Profile", "Add details"),
StepItem(4, "Finish", "You're ready!")
)
MaterialTheme {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(text = "VerticalStepper", fontWeight = FontWeight.Bold)
VerticalStepper(steps = demoSteps)
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(text = "HorizontalStepper", fontWeight = FontWeight.Bold)
HorizontalStepper(steps = demoSteps)
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(text = "CompactHorizontalStepper", fontWeight = FontWeight.Bold)
CompactHorizontalStepper(steps = demoSteps)
}
}
}
You can instantly see all three stepper types in one preview!🎨 Customization Tips
Here’s how you can tailor steppers to your brand or design system:Property | Description |
---|---|
accent |
Change progress color |
nodeSize |
Adjust node circle size |
lineWidth / lineHeight |
Change connector thickness |
nodeContentPadding |
Control inner spacing for icons/images |
tonalElevation |
Adjust 3D look of nodes |
🔧 Integrating in Your App
You can easily plug these steppers into any Jetpack Compose screen:VerticalStepper(
steps = mySteps,
accent = Color(0xFF2196F3),
onStepClick = { step -> /* handle click */ }
)
To use network images, integrate Coil:image = rememberAsyncImagePainter("https://example.com/avatar.png")
Final Compete Code:
package com.codingbihar.stepperdemo
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
// --- Models
data class StepItem(
val id: Int,
val title: String,
val subtitle: String? = null,
val image: Painter? = null,
val icon: ImageVector? = null,
val completed: Boolean = false
)
// --- VerticalStepper
@Composable
fun VerticalStepper(
steps: List,
modifier: Modifier = Modifier,
nodeSize: Dp = 56.dp,
lineWidth: Dp = 2.dp,
nodeContentPadding: Dp = 8.dp,
accent: Color = MaterialTheme.colorScheme.primary,
onStepClick: (StepItem) -> Unit = {}
) {
Column(modifier = modifier) {
steps.forEachIndexed { index, step ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
// left: connector + node
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(nodeSize)
.padding(top = if (index == 0) 0.dp else 8.dp)
) {
// top connector
if (index != 0) {
Canvas(modifier = Modifier
.height(12.dp)
.width(lineWidth)
) {
drawRect(color = if (steps[index - 1].completed) accent else Color.LightGray)
}
} else Spacer(modifier = Modifier.height(12.dp))
// node
Surface(
shape = CircleShape,
tonalElevation = 2.dp,
modifier = Modifier
.size(nodeSize)
.clickable { onStepClick(step) }
) {
Box(contentAlignment = Alignment.Center) {
if (step.image != null) {
Image(
painter = step.image,
contentDescription = step.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
)
} else if (step.icon != null) {
Icon(
imageVector = step.icon,
contentDescription = step.title,
modifier = Modifier.size(nodeSize - nodeContentPadding)
)
} else {
// fallback: step number
Text(
text = (index + 1).toString(),
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
}
}
}
// bottom connector
if (index != steps.lastIndex) {
Canvas(modifier = Modifier
.height(24.dp)
.width(lineWidth)
) {
drawRect(color = if (step.completed) accent else Color.LightGray)
}
}
}
// right: content
Column(modifier = Modifier
.padding(start = 12.dp, bottom = 12.dp)
.fillMaxWidth()
) {
Text(text = step.title, fontWeight = FontWeight.SemiBold)
step.subtitle?.let {
Text(text = it, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
// --- HorizontalStepper (full) -- simple clean horizontal layout
@Composable
fun HorizontalStepper(
steps: List,
modifier: Modifier = Modifier,
nodeSize: Dp = 56.dp,
lineHeight: Dp = 2.dp,
accent: Color = MaterialTheme.colorScheme.primary,
onStepClick: (StepItem) -> Unit = {}
) {
Row(modifier = modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
steps.forEachIndexed { index, step ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable { onStepClick(step) }) {
Surface(shape = CircleShape, modifier = Modifier.size(nodeSize)) {
Box(contentAlignment = Alignment.Center) {
if (step.image != null) {
Image(painter = step.image, contentDescription = step.title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else if (step.icon != null) {
Icon(imageVector = step.icon, contentDescription = step.title, modifier = Modifier.size(nodeSize - 16.dp))
} else {
Text(text = (index + 1).toString(), fontWeight = FontWeight.Bold)
}
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(text = step.title, style = MaterialTheme.typography.bodySmall)
}
if (index != steps.lastIndex) {
Spacer(modifier = Modifier.width(8.dp))
// line
Box(modifier = Modifier
.height(lineHeight)
.weight(1f)
.align(Alignment.CenterVertically)
.background(color = if (step.completed) accent else Color.LightGray)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
// --- CompactHorizontalStepper: icon-only compact version for mobile UIs
@Composable
fun CompactHorizontalStepper(
steps: List,
modifier: Modifier = Modifier,
nodeSize: Dp = 40.dp,
accent: Color = MaterialTheme.colorScheme.primary,
onStepClick: (StepItem) -> Unit = {}
) {
Row(modifier = modifier.padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
steps.forEachIndexed { index, step ->
Box(modifier = Modifier
.size(nodeSize)
.clickable { onStepClick(step) }, contentAlignment = Alignment.Center) {
Surface(shape = CircleShape, tonalElevation = if (step.completed) 4.dp else 0.dp) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(nodeSize)) {
if (step.icon != null) {
Icon(imageVector = step.icon, contentDescription = step.title, modifier = Modifier.size(nodeSize - 12.dp))
} else {
Text(text = "${index + 1}", fontSize = 12.sp)
}
}
}
}
if (index != steps.lastIndex) {
Spacer(modifier = Modifier.width(8.dp))
Box(modifier = Modifier
.height(2.dp)
.weight(1f)
.align(Alignment.CenterVertically)
.background(color = if (step.completed) accent else Color.LightGray)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
// --- Previews (demo data)
@Preview(showBackground = true)
@Composable
fun PreviewSteppers() {
val demoSteps = remember {
listOf(
StepItem(1, "Start", "Create account", image = null, completed = true),
StepItem(2, "Verify", "Email verification", image = null, completed = true),
StepItem(3, "Profile", "Add details", image = null, completed = false),
StepItem(4, "Finish", "You're ready!", image = null, completed = false)
)
}
MaterialTheme {
Column(modifier = Modifier.fillMaxSize().systemBarsPadding().padding(16.dp)) {
Text(text = "VerticalStepper", fontWeight = FontWeight.Bold)
VerticalStepper(steps = demoSteps, modifier = Modifier.padding(vertical = 8.dp))
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(text = "HorizontalStepper", fontWeight = FontWeight.Bold)
HorizontalStepper(steps = demoSteps, modifier = Modifier.fillMaxWidth())
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(text = "CompactHorizontalStepper", fontWeight = FontWeight.Bold)
CompactHorizontalStepper(steps = demoSteps)
}
}
}