Jetpack Compose Steppers: Create Beautiful Vertical & Horizontal Progress Indicators
π― What is a Stepper?
π§© Step 1: Defining the Data Model
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
)- title and optional subtitle
- either an image (like a user profile picture) or an icon
- completed flag to style finished steps differently
π§± Step 2: Creating the Vertical Stepper
@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?
✨ Example Preview
VerticalStepper(
steps = demoSteps,
modifier = Modifier.padding(vertical = 8.dp)
)➡️ Step 3: Building the Horizontal Stepper
@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.
⚡ 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)
}
}
}

