```html SwiftUI: How to Build a Loading Spinner (iOS 17+, 2026)

How to Build a Loading Spinner in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: ProgressView Updated: May 11, 2026
TL;DR

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

  1. @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 to true before the async work and false after is all you need.
  2. ZStack overlay pattern — Placing the spinner inside a ZStack on 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.
  3. .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 .linear later without restructuring.
  4. defer { isLoading = false } — Using defer ensures the spinner hides even if the async function throws or returns early, preventing the UI from getting stuck in a loading state.
  5. 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

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?
Yes. 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?
In UIKit the spinner is 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.

```