How to implement circular progress in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Shape / trim Updated: May 11, 2026
TL;DR

Stack two Circle shapes: a static track ring and a trimmed progress arc rotated to start at 12 o'clock. Drive the .trim(from:to:) modifier with your progress value (0–1) and attach a .spring animation for smooth, physics-based updates.

struct CircularProgress: View {
    var progress: Double // 0.0 – 1.0

    var body: some View {
        ZStack {
            Circle()
                .stroke(Color(.systemGray5), lineWidth: 10)
            Circle()
                .trim(from: 0, to: progress)
                .stroke(Color.accentColor,
                        style: StrokeStyle(lineWidth: 10, lineCap: .round))
                .rotationEffect(.degrees(-90))
                .animation(.spring(response: 0.5, dampingFraction: 0.8),
                           value: progress)
        }
        .frame(width: 120, height: 120)
    }
}

Full implementation

The component below wraps the two-ring pattern in a configurable CircularProgressView with a percentage label, accessibility support, and customisable colors and stroke width. A @Previewable @State slider in the #Preview lets you scrub through the animation live in Xcode's canvas without any extra scaffolding.

import SwiftUI

// MARK: - Main component

struct CircularProgressView: View {
    /// Current progress, clamped to 0.0 – 1.0.
    var progress: Double
    var lineWidth: CGFloat     = 12
    var size: CGFloat          = 120
    var trackColor: Color      = Color(.systemGray5)
    var progressColor: Color   = .accentColor
    var showLabel: Bool        = true

    private var clampedProgress: Double {
        min(max(progress, 0), 1)
    }

    var body: some View {
        ZStack {
            // 1. Static background track
            Circle()
                .stroke(trackColor, lineWidth: lineWidth)

            // 2. Animated progress arc
            Circle()
                .trim(from: 0, to: clampedProgress)
                .stroke(
                    progressColor,
                    style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
                )
                // Start arc at top (12 o'clock)
                .rotationEffect(.degrees(-90))
                .animation(
                    .spring(response: 0.55, dampingFraction: 0.75),
                    value: clampedProgress
                )

            // 3. Optional percentage label
            if showLabel {
                VStack(spacing: 2) {
                    Text("\(Int(clampedProgress * 100))%")
                        .font(.title3.bold().monospacedDigit())
                        .contentTransition(.numericText())
                    Text("complete")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                }
            }
        }
        .frame(width: size, height: size)
        // MARK: Accessibility
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("Progress")
        .accessibilityValue("\(Int(clampedProgress * 100)) percent complete")
    }
}

// MARK: - Preview

#Preview("Interactive") {
    @Previewable @State var progress: Double = 0.35

    VStack(spacing: 36) {
        HStack(spacing: 32) {
            CircularProgressView(progress: progress)
            CircularProgressView(progress: progress,
                                 lineWidth: 8,
                                 size: 80,
                                 progressColor: .green,
                                 showLabel: false)
            CircularProgressView(progress: progress,
                                 lineWidth: 16,
                                 size: 160,
                                 progressColor: .pink)
        }

        Slider(value: $progress, in: 0...1)
            .padding(.horizontal, 32)

        Button("Complete") {
            withAnimation { progress = 1.0 }
        }
        .buttonStyle(.borderedProminent)
    }
    .padding()
}

How it works

  1. Track ring via Circle().stroke() — The first Circle is stroked with a neutral trackColor and acts as the static background ring. It always spans the full 360°, giving the progress arc a defined rail to travel along.
  2. .trim(from: 0, to: clampedProgress) — This modifier clips how much of the circle's path is drawn. A value of 0.0 shows nothing; 1.0 draws the full circle. SwiftUI interpolates between values automatically when the binding changes.
  3. StrokeStyle(lineCap: .round) — Rounding the line cap gives the leading tip of the arc a polished, pill-shaped appearance. Without it the arc terminates with a flat butt cap that looks abrupt at low progress values.
  4. .rotationEffect(.degrees(-90)) — SwiftUI's path system starts arcs at 3 o'clock (0°). Rotating the entire Circle view by −90° shifts the start point to 12 o'clock, matching every standard progress indicator convention.
  5. Spring animation on clampedProgress — Attaching .animation(.spring(...), value: clampedProgress) means any external change to progress — whether from a timer, a network callback, or a slider — automatically triggers the spring transition without callers needing to wrap updates in withAnimation.

Variants

Gradient progress arc

Replace the flat Color stroke with an AngularGradient for a vivid, modern look. Because .trim works on the shape's path before the fill/stroke is applied, the gradient rotates with the arc automatically.

struct GradientCircularProgress: View {
    var progress: Double

    private let gradient = AngularGradient(
        colors: [.purple, .pink, .orange],
        center: .center,
        startAngle: .degrees(0),
        endAngle: .degrees(360)
    )

    var body: some View {
        ZStack {
            Circle()
                .stroke(Color(.systemGray6), lineWidth: 14)
            Circle()
                .trim(from: 0, to: progress)
                .stroke(gradient,
                        style: StrokeStyle(lineWidth: 14, lineCap: .round))
                .rotationEffect(.degrees(-90))
                .animation(.spring(response: 0.6, dampingFraction: 0.8),
                           value: progress)
        }
        .frame(width: 140, height: 140)
    }
}

#Preview {
    GradientCircularProgress(progress: 0.72)
        .padding()
}

Auto-incrementing timer ring

Drive progress from a TimelineView or a Timer publisher to build a countdown or elapsed-time indicator. Store the start date in @State, compute elapsed / duration inside body, and pass the result directly to CircularProgressView. The spring animation is already attached, so every frame transition is silky smooth without any extra animation calls.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement circular progress in SwiftUI for iOS 17+.
Use Shape/trim with StrokeStyle(lineCap: .round) and
rotationEffect(.degrees(-90)).
Make it accessible (VoiceOver labels with accessibilityValue).
Add a #Preview with a Slider so progress can be scrubbed live.

Drop this prompt into Soarias during the Build phase — it generates the component, wires it into your feature screen, and writes the preview so you can validate the animation in the canvas before moving to the Polish phase.

Related

FAQ

Does this work on iOS 16?

The .trim + .stroke combination compiles on iOS 16, but an animated cap-rendering bug causes visible glitches. Wrap progress updates in withAnimation at the call site rather than attaching .animation directly to the shape to sidestep the issue. iOS 17+ is the recommended minimum.

How do I animate the progress from a background async task?

Publish progress updates on the main actor — either via @MainActor on your view model method or by wrapping the assignment in await MainActor.run { ... }. Because .animation(.spring(...), value:) is already attached to the arc, any main-thread mutation of your @State or @Observable property automatically triggers the spring transition.

What's the UIKit equivalent?

In UIKit you'd use a CAShapeLayer with a UIBezierPath arc, then animate its strokeEnd property from 0 to your progress value via a CABasicAnimation. The SwiftUI .trim approach is considerably less code and integrates directly with the declarative state model — no delegate callbacks or layer management required.

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