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

- Progress
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- steps · stepper · progress · checkout · wizard · onboarding · timeline · pasos · progreso · asistente · pago · línea · numerados · etapas
Description
Section titled “Description”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) } } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
steps | [String] | Required |
currentStep | Int | Required |
tint | Color | .indigo |
Copy this code into your project, or download the file.
// 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()}