How to build a step indicator in SwiftUI
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
-
StepState enum — The three cases (
upcoming,active,completed) drive every visual difference: fill colour, overlay icon, and label tint. Centralising state here meansStepNodenever readscurrentStepdirectly — it's purely presentational. -
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. -
Connector Rectangle — Between each pair of nodes a
Rectangle().frame(height: 2)fills withColor.accentColorwhenindex < currentStep. The.padding(.bottom, 22)nudges it up to the visual centre of the 32 pt circle even though a label sits below. -
Spring animation — Both
StepNodeandConnectorattach.animation(.spring(duration: 0.3), value: state)so colour transitions animate smoothly without a parentwithAnimationcall. -
VoiceOver — Each node has an individual
.accessibilityLabeldescribing its position and state (e.g. "Step 2, Details, current"). The outerStepIndicatoralso 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
-
iOS 16 regression: The
#Previewmacro and.animation(_:value:)overload used here require iOS 17 / Xcode 15+. If you need iOS 16 support, replace#PreviewwithPreviewProviderand wrap state mutations inwithAnimationat the call site. -
Connector alignment: If you change the circle size, update the connector's
.padding(.bottom, …)offset to half the new diameter plus the label height — otherwise the line drifts above or below the circle centre. -
Dynamic Type truncation: Long step labels inside a fixed
maxWidth: 56frame will truncate at larger text sizes. Either increase the cap, use.minimumScaleFactor(0.7), or hide labels and rely solely on VoiceOver descriptions for accessibility.
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.