```html SwiftUI: How to Build Step Indicator (iOS 17+, 2026)

How to build a step indicator in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: HStack, Circle Updated: May 11, 2026
TL;DR

Compose a row of Circle views inside an HStack, colouring each based on whether its index is behind, equal to, or ahead of currentStep. A thin Rectangle connector between each pair of circles fills with your accent colour as the user advances.

struct StepIndicator: View {
    let steps: [String]
    @Binding var currentStep: Int

    var body: some View {
        HStack(spacing: 0) {
            ForEach(steps.indices, id: \.self) { index in
                Circle()
                    .fill(index <= currentStep ? Color.accentColor : Color.secondary.opacity(0.3))
                    .frame(width: 28, height: 28)
                    .overlay {
                        if index < currentStep {
                            Image(systemName: "checkmark")
                                .font(.caption.bold())
                                .foregroundStyle(.white)
                        } else {
                            Text("\(index + 1)")
                                .font(.caption.bold())
                                .foregroundStyle(index == currentStep ? .white : .secondary)
                        }
                    }
                if index < steps.count - 1 {
                    Rectangle()
                        .fill(index < currentStep ? Color.accentColor : Color.secondary.opacity(0.3))
                        .frame(height: 2)
                }
            }
        }
    }
}

Full implementation

The component below adds step labels beneath each node, smooth colour transitions via .animation(.spring, value:), and full VoiceOver accessibility so assistive technology announces the current position in the flow. A demo wrapper with Next / Back buttons lets you validate every state in the preview canvas.

import SwiftUI

// MARK: - Step state

enum StepState {
    case upcoming, active, completed
}

// MARK: - Single node

private struct StepNode: View {
    let index: Int
    let label: String
    let state: StepState

    var body: some View {
        VStack(spacing: 6) {
            Circle()
                .fill(nodeFill)
                .frame(width: 32, height: 32)
                .overlay { nodeOverlay }
                .overlay {
                    Circle()
                        .strokeBorder(
                            state == .active ? Color.accentColor : Color.clear,
                            lineWidth: 2.5
                        )
                        .padding(-3)
                }
                .animation(.spring(duration: 0.3), value: state)
                .accessibilityLabel(accessibilityDescription)

            Text(label)
                .font(.caption2)
                .foregroundStyle(state == .upcoming ? .secondary : .primary)
                .multilineTextAlignment(.center)
                .frame(maxWidth: 56)
        }
    }

    private var nodeFill: Color {
        switch state {
        case .completed: return .accentColor
        case .active:    return .accentColor.opacity(0.15)
        case .upcoming:  return Color(.systemFill)
        }
    }

    @ViewBuilder
    private var nodeOverlay: some View {
        switch state {
        case .completed:
            Image(systemName: "checkmark")
                .font(.caption.bold())
                .foregroundStyle(.white)
        case .active:
            Text("\(index + 1)")
                .font(.caption.bold())
                .foregroundStyle(.accentColor)
        case .upcoming:
            Text("\(index + 1)")
                .font(.caption.bold())
                .foregroundStyle(.secondary)
        }
    }

    private var accessibilityDescription: String {
        switch state {
        case .completed: return "Step \(index + 1), \(label), completed"
        case .active:    return "Step \(index + 1), \(label), current"
        case .upcoming:  return "Step \(index + 1), \(label), upcoming"
        }
    }
}

// MARK: - Connector line

private struct Connector: View {
    let filled: Bool

    var body: some View {
        Rectangle()
            .fill(filled ? Color.accentColor : Color(.systemFill))
            .frame(height: 2)
            .animation(.spring(duration: 0.3), value: filled)
    }
}

// MARK: - Step indicator

struct StepIndicator: View {
    let steps: [String]
    @Binding var currentStep: Int

    var body: some View {
        HStack(spacing: 0) {
            ForEach(steps.indices, id: \.self) { index in
                StepNode(
                    index: index,
                    label: steps[index],
                    state: stepState(for: index)
                )

                if index < steps.count - 1 {
                    Connector(filled: index < currentStep)
                        .padding(.bottom, 22) // align with circle centre
                }
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("Step \(currentStep + 1) of \(steps.count): \(steps[currentStep])")
    }

    private func stepState(for index: Int) -> StepState {
        if index < currentStep  { return .completed }
        if index == currentStep { return .active }
        return .upcoming
    }
}

// MARK: - Demo wrapper

private struct StepIndicatorDemo: View {
    @State private var step = 0
    private let steps = ["Account", "Details", "Review", "Confirm"]

    var body: some View {
        VStack(spacing: 40) {
            StepIndicator(steps: steps, currentStep: $step)
                .padding(.horizontal)

            Text("Step \(step + 1): \(steps[step])")
                .font(.title3.bold())

            HStack(spacing: 16) {
                Button("← Back") { if step > 0 { step -= 1 } }
                    .buttonStyle(.bordered)
                    .disabled(step == 0)

                Button(step < steps.count - 1 ? "Next →" : "Done ✓") {
                    if step < steps.count - 1 { step += 1 }
                }
                .buttonStyle(.borderedProminent)
            }
        }
        .padding()
    }
}

#Preview {
    StepIndicatorDemo()
}

How it works

  1. StepState enum — The three cases (upcoming, active, completed) drive every visual difference: fill colour, overlay icon, and label tint. Centralising state here means StepNode never reads currentStep directly — it's purely presentational.
  2. Circle + strokeBorder ring — The active node gets a pulsing accent ring via a secondary .overlay { Circle().strokeBorder(...) } with negative padding so it sits outside the filled circle without affecting layout.
  3. Connector Rectangle — Between each pair of nodes a Rectangle().frame(height: 2) fills with Color.accentColor when index < currentStep. The .padding(.bottom, 22) nudges it up to the visual centre of the 32 pt circle even though a label sits below.
  4. Spring animation — Both StepNode and Connector attach .animation(.spring(duration: 0.3), value: state) so colour transitions animate smoothly without a parent withAnimation call.
  5. VoiceOver — Each node has an individual .accessibilityLabel describing its position and state (e.g. "Step 2, Details, current"). The outer StepIndicator also has a combined label so users can swipe past the whole widget as one element when they want.

Variants

Icon steps instead of numbers

Replace the number text with SF Symbol names stored alongside each step label.

struct IconStep {
    let label: String
    let icon: String   // SF Symbol name
}

private struct IconStepNode: View {
    let step: IconStep
    let state: StepState

    var body: some View {
        VStack(spacing: 6) {
            Circle()
                .fill(state == .upcoming
                      ? Color(.systemFill)
                      : Color.accentColor)
                .frame(width: 36, height: 36)
                .overlay {
                    Image(systemName:
                        state == .completed ? "checkmark" : step.icon)
                        .font(.system(size: 15, weight: .semibold))
                        .foregroundStyle(
                            state == .upcoming ? .secondary : .white)
                }

            Text(step.label)
                .font(.caption2)
                .foregroundStyle(state == .upcoming ? .secondary : .primary)
        }
    }
}

Vertical layout

Swap HStack for a VStack, rotate the connector Rectangle to .frame(width: 2).frame(maxHeight: .infinity), and move labels to a trailing HStack beside each node. This pattern works well in settings flows or long onboarding sequences where more than four steps would crowd a horizontal bar.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a step indicator in SwiftUI for iOS 17+.
Use HStack and Circle to render numbered nodes connected
by thin Rectangle lines.
Drive it from a @Binding<Int> currentStep and a [String] steps array.
Support completed (checkmark), active (accent ring), and upcoming states.
Make it accessible (VoiceOver labels for each node and the group).
Add a #Preview with a 4-step demo including Next/Back buttons
and realistic sample data (e.g. "Account", "Details", "Review", "Confirm").

In Soarias's Build phase, paste this prompt into a feature branch task to scaffold the component, then use the inline diff viewer to review and accept only the lines that fit your app's design tokens before committing.

Related

FAQ

Does this work on iOS 16?

The core HStack / Circle structure works on iOS 16, but you must replace the #Preview macro with a PreviewProvider struct, and wrap currentStep mutations in withAnimation manually since the value-based animation modifier behaves differently pre-iOS 17.

How do I prevent users from jumping to a future step by tapping?

Attach .onTapGesture to each StepNode only when state == .completed, and guard with if index < currentStep { currentStep = index }. This lets users revisit previous steps without skipping ahead — a common UX requirement in onboarding and checkout flows.

What's the UIKit equivalent?

UIKit has no native step indicator. The common UIKit approach is a horizontal UIStackView of custom UIView subclasses backed by CAShapeLayer circles — far more boilerplate than SwiftUI's declarative equivalent. If you're bridging into a UIKit host, wrap StepIndicator in a UIHostingController.

Last reviewed: 2026-05-11 by the Soarias team.

```