```html SwiftUI: How to Build a Progress Ring (iOS 17+, 2026)

How to build a progress ring in SwiftUI

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

Layer two Circle() shapes in a ZStack: one as a static track, one with .trim(from: 0, to: progress) for the arc. Rotate −90° so it starts at 12 o'clock, then animate with .animation(.spring, value: progress).

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

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

Full implementation

The complete version adds a percentage label in the center, configurable colors and line width, and full VoiceOver support via .accessibilityLabel and .accessibilityValue. A live demo view with a Slider lets you scrub the progress interactively in Xcode Previews.

import SwiftUI

// MARK: - Reusable ProgressRing component

struct ProgressRing: View {
    /// Current progress from 0.0 (empty) to 1.0 (full)
    var progress: Double
    var ringColor: Color = .blue
    var trackColor: Color = Color(.systemGray5)
    var lineWidth: CGFloat = 16
    var size: CGFloat = 140

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

            // Animated progress arc
            Circle()
                .trim(from: 0, to: max(0, min(progress, 1)))
                .stroke(
                    ringColor,
                    style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
                )
                // Start at 12 o'clock instead of 3 o'clock
                .rotationEffect(.degrees(-90))
                .animation(
                    .spring(response: 0.55, dampingFraction: 0.78),
                    value: progress
                )

            // Center percentage label
            VStack(spacing: 2) {
                Text("\(Int(progress * 100))%")
                    .font(.system(.title2, design: .rounded, weight: .bold))
                    .contentTransition(.numericText())
                    .animation(.spring(response: 0.55), value: progress)

                Text("complete")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }
        }
        .frame(width: size, height: size)
        // Treat the whole ring as a single accessibility element
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("Progress ring")
        .accessibilityValue("\(Int(progress * 100)) percent complete")
    }
}

// MARK: - Demo container

struct ProgressRingDemo: View {
    @State private var progress: Double = 0.35

    var body: some View {
        VStack(spacing: 40) {
            ProgressRing(progress: progress)

            VStack(spacing: 8) {
                Text("Drag to update")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Slider(value: $progress, in: 0...1)
                    .padding(.horizontal)
                    .tint(.blue)
            }

            HStack(spacing: 16) {
                Button("Reset") {
                    withAnimation { progress = 0 }
                }
                Button("Complete") {
                    withAnimation { progress = 1.0 }
                }
            }
            .buttonStyle(.bordered)
        }
        .padding()
        .navigationTitle("Progress Ring")
    }
}

// MARK: - Preview

#Preview("Interactive") {
    NavigationStack {
        ProgressRingDemo()
    }
}

#Preview("Variants") {
    HStack(spacing: 24) {
        ProgressRing(progress: 0.25, ringColor: .red, size: 90)
        ProgressRing(progress: 0.60, ringColor: .orange, size: 90)
        ProgressRing(progress: 1.0,  ringColor: .green,  size: 90)
    }
    .padding()
}

How it works

  1. 1
    Track circle — The first Circle().stroke(trackColor, lineWidth:) draws the full 360° ring in a muted gray. This never changes and gives the visual "rail" that the progress arc rides along.
  2. 2
    .trim(from: 0, to: progress) — This modifier clips the circle's stroke path to a fraction of its total circumference. A value of 0.75 renders a 270° arc. SwiftUI automatically interpolates the to: parameter when it changes, making animation trivial.
  3. 3
    .rotationEffect(.degrees(-90)) — SwiftUI's coordinate system puts the arc's origin at 3 o'clock (east). Rotating by −90° shifts the start point to 12 o'clock (north), which matches every progress ring convention users expect.
  4. 4
    Spring animation.animation(.spring(response: 0.55, dampingFraction: 0.78), value: progress) ties physics-based interpolation directly to the progress binding. Whenever progress changes externally, SwiftUI smoothly springs the arc to the new value without any extra state management.
  5. 5
    Accessibility.accessibilityElement(children: .ignore) collapses the ZStack into a single VoiceOver node. The explicit .accessibilityValue announces a human-friendly percentage so screen readers don't read raw floating-point numbers.

Variants

Gradient arc

Replace the solid Color with an AngularGradient for a polished, modern look — common in fitness and health apps.

struct GradientProgressRing: View {
    var progress: Double
    private let gradient = AngularGradient(
        colors: [.blue, .purple, .pink, .blue],
        center: .center,
        startAngle: .degrees(-90),
        endAngle: .degrees(270)
    )

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

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

Stacked multi-ring (Activity-style)

Compose three ProgressRing instances with decreasing size values (140, 104, 68) and different ringColors inside a single ZStack. Each ring animates independently because each has its own progress binding — no extra coordination needed.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a progress ring in SwiftUI for iOS 17+.
Use Shape, trim, and Animation (spring physics).
Make it accessible (VoiceOver labels for percentage).
Support configurable ringColor, trackColor, lineWidth, and size.
Add a #Preview with realistic sample data showing three rings at 25%, 60%, and 100%.

In Soarias's Build phase, paste this prompt into the Claude Code panel alongside your feature branch — Soarias's local-first context keeps your SwiftData models and design tokens in scope so the generated ring snaps straight into your existing app without manual wiring.

Related SwiftUI guides

FAQ

Does this work on iOS 16?

Yes — Shape, .trim(), and spring animations have been available since SwiftUI's debut. The only iOS 17-specific addition in this guide is the #Preview macro (use the PreviewProvider protocol on iOS 16) and .contentTransition(.numericText()) which requires iOS 16. Wrap the latter in if #available(iOS 16, *) if you need broader deployment.

How do I animate the ring when the view first appears?

Start progress at 0.0 and update it inside .onAppear:

@State private var progress = 0.0

var body: some View {
    ProgressRing(progress: progress)
        .onAppear {
            withAnimation(.spring(response: 1.0, dampingFraction: 0.8)) {
                progress = targetValue
            }
        }
}

This triggers a one-shot fill animation the moment the view enters the hierarchy.

What's the UIKit equivalent?

In UIKit you'd use a CAShapeLayer with a UIBezierPath(arcCenter:) and animate the layer's strokeEnd property via CABasicAnimation. SwiftUI's .trim() + spring animation replaces all of that with a handful of readable modifier calls — no manual CATransaction setup required.

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

```