How to implement circular progress in SwiftUI
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
-
Track ring via
Circle().stroke()— The firstCircleis stroked with a neutraltrackColorand acts as the static background ring. It always spans the full 360°, giving the progress arc a defined rail to travel along. -
.trim(from: 0, to: clampedProgress)— This modifier clips how much of the circle's path is drawn. A value of0.0shows nothing;1.0draws the full circle. SwiftUI interpolates between values automatically when the binding changes. -
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. -
.rotationEffect(.degrees(-90))— SwiftUI's path system starts arcs at 3 o'clock (0°). Rotating the entireCircleview by −90° shifts the start point to 12 o'clock, matching every standard progress indicator convention. -
Spring animation on
clampedProgress— Attaching.animation(.spring(...), value: clampedProgress)means any external change toprogress— whether from a timer, a network callback, or a slider — automatically triggers the spring transition without callers needing to wrap updates inwithAnimation.
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
- iOS 16 regression:
.trimwith animatedstrokehad a rendering glitch in iOS 16 where the arc cap would jump. The bug is resolved in iOS 17. If you must support iOS 16, animate withwithAnimationat the call site instead of attaching.animationto the shape. - ZStack draw order matters: Place the track ring before the progress arc in the
ZStack. Reversing the order paints the track on top of the arc, hiding it entirely at low progress values. - Missing accessibility value: VoiceOver reads
Circleshapes as "ellipse" with no progress context. Always attach.accessibilityElement(children: .ignore)and.accessibilityValueso assistive technology announces the actual percentage. The full implementation above includes this out of the box.
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.