How to Build a Loading Spinner in SwiftUI
Drop ProgressView() into any SwiftUI view and it renders an indeterminate spinner instantly. Wrap it in an if isLoading guard so it appears and disappears with your async work.
struct LoadingView: View {
@State private var isLoading = false
var body: some View {
VStack(spacing: 16) {
if isLoading {
ProgressView()
.tint(.indigo)
.scaleEffect(1.5)
}
Button("Load") { isLoading.toggle() }
}
}
}
Full implementation
The example below simulates a network fetch using Task and try await Task.sleep. The spinner is shown while the task is in-flight and hidden once the data arrives. A labeled variant using ProgressView("Loading…") is included so VoiceOver users hear a meaningful description. The overlay approach keeps the spinner centred without disrupting the underlying layout.
import SwiftUI
struct ContentView: View {
@State private var isLoading = false
@State private var items: [String] = []
var body: some View {
NavigationStack {
ZStack {
listContent
if isLoading {
spinnerOverlay
}
}
.navigationTitle("Articles")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Refresh") {
Task { await loadData() }
}
.disabled(isLoading)
}
}
}
.task { await loadData() }
}
// MARK: – Subviews
private var listContent: some View {
List(items, id: \.self) { item in
Text(item)
}
.opacity(isLoading ? 0.3 : 1)
.animation(.easeInOut(duration: 0.2), value: isLoading)
}
private var spinnerOverlay: some View {
VStack(spacing: 12) {
ProgressView()
.progressViewStyle(.circular)
.tint(.indigo)
.scaleEffect(1.4)
Text("Loading…")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(24)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.accessibilityElement(children: .combine)
.accessibilityLabel("Loading content")
}
// MARK: – Data
private func loadData() async {
isLoading = true
defer { isLoading = false }
// Simulate a 1.5-second network request
try? await Task.sleep(for: .seconds(1.5))
items = (1...12).map { "Article \($0)" }
}
}
#Preview {
ContentView()
}
How it works
-
@State private var isLoading— A single boolean drives all visibility logic. SwiftUI re-renders the view automatically each time its value changes, so setting it totruebefore the async work andfalseafter is all you need. -
ZStackoverlay pattern — Placing the spinner inside aZStackon top of the list keeps the layout stable. The list content stays in the view hierarchy (dimmed via.opacity(0.3)) so it doesn't jump when the spinner disappears. -
.progressViewStyle(.circular)— On iOS this renders the classic UIActivityIndicator-style spinner. Omitting the modifier gives you the same result; including it makes the intent explicit and allows you to swap in.linearlater without restructuring. -
defer { isLoading = false }— Usingdeferensures the spinner hides even if the async function throws or returns early, preventing the UI from getting stuck in a loading state. -
Accessibility grouping —
.accessibilityElement(children: .combine)merges the spinner and the "Loading…" label into one VoiceOver element, and.accessibilityLabel("Loading content")gives screen-reader users a clear announcement when the overlay appears.
Variants
Determinate progress bar (known completion %)
struct UploadProgressView: View {
@State private var progress: Double = 0.0
@State private var isUploading = false
var body: some View {
VStack(spacing: 20) {
if isUploading {
ProgressView(value: progress, total: 1.0) {
Text("Uploading…")
.font(.caption)
.foregroundStyle(.secondary)
} currentValueLabel: {
Text("\(Int(progress * 100))%")
.font(.caption2.monospacedDigit())
}
.tint(.indigo)
.padding(.horizontal)
}
Button(isUploading ? "Cancel" : "Upload") {
isUploading.toggle()
if isUploading { simulateUpload() }
}
}
}
private func simulateUpload() {
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
progress += 0.02
if progress >= 1.0 {
timer.invalidate()
isUploading = false
progress = 0
}
}
}
}
#Preview { UploadProgressView() }
Full-screen blocking spinner with dimmed background
For multi-step operations where the user must wait, apply the spinner as a full-screen cover using .overlay on the root view with a Color.black.opacity(0.4) background and .ignoresSafeArea(). Pair this with .interactiveDismissDisabled(true) on sheets to prevent accidental dismissal mid-operation. For lighter use cases, SwiftUI's built-in .opacity animation on the overlay container is enough — avoid adding a separate withAnimation block as it can conflict with SwiftUI's own transition engine on iOS 17.
Common pitfalls
-
iOS 16 label API changed. The two-closure form of
ProgressView(value:total:label:currentValueLabel:)using trailing closures was introduced in iOS 16, but thecurrentValueLabelparameter gained richer layout support in iOS 17. If you target iOS 16, test both closures render correctly; the spinner itself works back to iOS 14. -
Updating
isLoadingoff the main actor crashes. If your async work runs on a background actor, publishing UI state changes from there raises a purple runtime warning (or crash in strict concurrency). Always updateisLoadingwithawait MainActor.run { isLoading = false }or annotate the calling function@MainActor. -
Don't forget VoiceOver. A bare
ProgressView()with no label is announced as "Progress indicator, 0%" by VoiceOver, which is confusing. Always provide either a string labelProgressView("Loading")or an explicit.accessibilityLabelso users with assistive technology understand what's happening.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a loading spinner in SwiftUI for iOS 17+. Use ProgressView with .progressViewStyle(.circular) and .tint(). Show the spinner while an async data fetch is in-flight using @State. Make it accessible (VoiceOver labels via .accessibilityLabel). Add a #Preview with realistic sample data showing the loaded state.
In the Soarias Build phase, paste this prompt into the active sprint task to have Claude Code scaffold the spinner component, wire it to your existing async data layer, and drop it directly into your targeted SwiftUI file — no manual copy-paste required.
Related
FAQ
Does this work on iOS 16?
ProgressView has been available since iOS 14. The .tint() modifier for coloring the spinner was added in iOS 15. The #Preview macro requires Xcode 15+ but the runtime code targets iOS 14+. Everything in this guide's main example compiles and runs on iOS 16 without changes.
How do I change the spinner size without using .scaleEffect?
.scaleEffect is the idiomatic SwiftUI approach because ProgressView ignores .frame sizing — the circular style renders at a fixed system size (~20 pt). Scaling up with .scaleEffect(1.5) is pixel-perfect. If you need pixel-precise control (e.g., a 64 pt spinner), build a custom view using Canvas with a rotating arc path, or wrap a UIActivityIndicatorView via UIViewRepresentable and set its transform property.
What is the UIKit equivalent of ProgressView?
UIActivityIndicatorView for indeterminate spinners and UIProgressView for determinate bars. SwiftUI's ProgressView() maps to UIActivityIndicatorView, while ProgressView(value:total:) maps to UIProgressView. The .progressViewStyle(.circular) modifier forces the circular spinner even when a value is supplied, which has no direct UIKit parallel.
Last reviewed: 2026-05-11 by the Soarias team.