Skip to content

Step Progress Indicator

Numbered steps joined by a line that fills as the current step advances.

Step Progress Indicator, playing
Download .swift
Origin
Original work
Found by
steps · stepper · progress · checkout · wizard · onboarding · timeline · pasos · progreso · asistente · pago · línea · numerados · etapas

Numbered steps joined by a line that fills as the current step advances.

currentStep starts at 0. Passing steps.count marks every step as done. With Reduce Motion on, the steps change without animating.

struct StepProgressIndicatorDemo: View {
@State private var step = 0
private let steps = ["Cart", "Address", "Payment", "Review"]
private let symbols = ["cart.fill", "house.fill", "creditcard.fill", "checkmark.seal.fill"]
var body: some View {
VStack(spacing: 44) {
Image(systemName: symbols[min(step, symbols.count - 1)])
.font(.system(.largeTitle))
.imageScale(.large)
.foregroundStyle(.indigo.gradient)
.contentTransition(.symbolEffect(.replace))
// The symbols are not equally tall. One height keeps what is below in place.
.frame(height: 56)
.accessibilityHidden(true)
Text("Checkout")
.font(.system(.title, design: .rounded, weight: .bold))
StepProgressIndicator(steps: steps, currentStep: step)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(0.9))
step = (step + 1) % (steps.count + 1)
}
}
}
}
ParameterTypeDefault
steps[String]Required
currentStepIntRequired
tintColor.indigo

Copy this code into your project, or download the file.

StepProgressIndicator.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// Numbered steps joined by a line that fills as the current step advances.
///
/// `currentStep` starts at 0. Passing `steps.count` marks every step as done.
/// With Reduce Motion on, the steps change without animating.
struct StepProgressIndicator: View {
let steps: [String]
var currentStep: Int
var tint: Color = .indigo
@ScaledMetric(relativeTo: .subheadline) private var markerSize = 36
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let lineHeight: CGFloat = 4
private var current: Int { min(max(currentStep, 0), steps.count) }
private var lineFraction: CGFloat {
guard steps.count > 1 else { return 0 }
return CGFloat(min(current, steps.count - 1)) / CGFloat(steps.count - 1)
}
var body: some View {
HStack(alignment: .top, spacing: 0) {
ForEach(steps.indices, id: \.self) { index in
VStack(spacing: 8) {
marker(index)
Text(steps[index])
.font(.caption)
.foregroundStyle(index <= current ? .primary : .secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
}
.background(alignment: .topLeading) { line }
.animation(reduceMotion ? nil : .smooth(duration: 0.45), value: current)
.accessibilityElement()
.accessibilityLabel("Progress")
.accessibilityValue(accessibilityText)
}
private var line: some View {
GeometryReader { proxy in
let inset = proxy.size.width / CGFloat(max(steps.count, 1) * 2)
let length = proxy.size.width - inset * 2
ZStack(alignment: .leading) {
Capsule()
.fill(Color(.systemGray5))
Capsule()
.fill(tint)
.frame(width: length * lineFraction)
}
.frame(width: length, height: lineHeight)
.offset(x: inset, y: (markerSize - lineHeight) / 2)
}
}
private func marker(_ index: Int) -> some View {
let isDone = index < current
let isCurrent = index == current
return ZStack {
Circle()
.fill(isDone || isCurrent ? tint : Color(.systemGray5))
if isDone {
Image(systemName: "checkmark")
.fontWeight(.bold)
.transition(.scale.combined(with: .opacity))
} else {
Text("\(index + 1)")
}
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(isDone || isCurrent ? Color.white : Color.primary)
.frame(width: markerSize, height: markerSize)
.background {
Circle()
.fill(tint.opacity(0.25))
.padding(-5)
.opacity(isCurrent ? 1 : 0)
}
}
private var accessibilityText: String {
guard current < steps.count else { return "All \(steps.count) steps done" }
return "Step \(current + 1) of \(steps.count), \(steps[current])"
}
}
// MARK: - Demo
struct StepProgressIndicatorDemo: View {
@State private var step = 0
private let steps = ["Cart", "Address", "Payment", "Review"]
private let symbols = ["cart.fill", "house.fill", "creditcard.fill", "checkmark.seal.fill"]
var body: some View {
VStack(spacing: 44) {
Image(systemName: symbols[min(step, symbols.count - 1)])
.font(.system(.largeTitle))
.imageScale(.large)
.foregroundStyle(.indigo.gradient)
.contentTransition(.symbolEffect(.replace))
// The symbols are not equally tall. One height keeps what is below in place.
.frame(height: 56)
.accessibilityHidden(true)
Text("Checkout")
.font(.system(.title, design: .rounded, weight: .bold))
StepProgressIndicator(steps: steps, currentStep: step)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(0.9))
step = (step + 1) % (steps.count + 1)
}
}
}
}
#Preview {
StepProgressIndicatorDemo()
}