How to build a progress ring in SwiftUI
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
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
.trim(from: 0, to: progress)— This modifier clips the circle's stroke path to a fraction of its total circumference. A value of0.75renders a 270° arc. SwiftUI automatically interpolates theto:parameter when it changes, making animation trivial. -
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
Spring animation —
.animation(.spring(response: 0.55, dampingFraction: 0.78), value: progress)ties physics-based interpolation directly to theprogressbinding. Wheneverprogresschanges externally, SwiftUI smoothly springs the arc to the new value without any extra state management. -
5
Accessibility —
.accessibilityElement(children: .ignore)collapses theZStackinto a single VoiceOver node. The explicit.accessibilityValueannounces 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
-
⚠
iOS version gotcha:
.contentTransition(.numericText())on the center label requires iOS 16+. If you need iOS 15 support, remove it — the ring itself works on iOS 15, but the numeric cross-fade will cause a compile error without an availability guard. -
⚠
Trim clamps at 1.0: Passing a value above
1.0to.trim(from:to:)causes the arc to disappear entirely because SwiftUI wraps around. Always clamp withmax(0, min(progress, 1))before passing to trim. -
⚠
Animation double-fire: If you wrap a
progressupdate in bothwithAnimation {}and also apply.animation(value:)on the view, the arc will animate twice and appear glitchy. Pick one: either drive animations from the view modifier (preferred) or from the call site — not both.
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.