Circular Progress Ring
An animated gradient ring that shows progress with a percentage in the middle.

- Progress
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- progress · ring · circular · gradient · percentage · activity ring · progreso · anillo · circular · degradado · porcentaje · indicador
Description
Section titled “Description”An animated ring with a gradient and the percentage in the middle. Give it a frame to size it.
With Reduce Motion on, the ring and the number change without animating.
struct CircularProgressRingDemo: View { @State private var progress = 0.0 @ScaledMetric(relativeTo: .largeTitle) private var percentSize = 60
var body: some View { VStack(spacing: 40) { Text("Uploading photos") .font(.system(.title, design: .rounded, weight: .bold))
CircularProgressRing( progress: progress, lineWidth: 26, font: .system(size: percentSize, weight: .bold, design: .rounded), label: "Upload" ) .frame(width: 280)
HStack(spacing: 28) { CircularProgressRing(progress: 0.32, colors: [.pink, .orange], lineWidth: 8, font: .headline, label: "Move") CircularProgressRing(progress: 0.68, colors: [.green, .mint], lineWidth: 8, font: .headline, label: "Exercise") CircularProgressRing(progress: 0.91, colors: [.teal, .blue], lineWidth: 8, font: .headline, label: "Stand") } .frame(height: 88) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) .task { let values = [0.25, 0.6, 0.85, 1, 0] var index = 0 while !Task.isCancelled { progress = values[index] index = (index + 1) % values.count try? await Task.sleep(for: .seconds(0.9)) } } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
progress | Double | Required |
colors | [Color] | [.cyan, .blue, .purple] |
lineWidth | CGFloat | 18 |
font | Font | .system(.largeTitle, design: .rounded, weight: .bold) |
label | LocalizedStringKey | "Progress" |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// The clamping and the text of the ring, apart from the view so they can be tested.enum CircularProgressRingMath { static func clamped(_ value: Double) -> Double { value.isNaN ? 0 : min(max(value, 0), 1) }
/// Only a finished ring reads 100: 99.6% rounds down to 99. static func wholePercent(_ value: Double) -> Int { let fraction = clamped(value) let percent = Int((fraction * 100).rounded()) return percent == 100 && fraction < 1 ? 99 : percent }
static func percentText(_ value: Double, locale: Locale = .autoupdatingCurrent) -> String { wholePercent(value).formatted(.percent.locale(locale)) }}
/// An animated ring with a gradient and the percentage in the middle. Give it a frame to size it.////// With Reduce Motion on, the ring and the number change without animating.struct CircularProgressRing: View { var progress: Double var colors: [Color] = [.cyan, .blue, .purple] var lineWidth: CGFloat = 18 var font: Font = .system(.largeTitle, design: .rounded, weight: .bold) var label: LocalizedStringKey = "Progress"
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var value: Double { CircularProgressRingMath.clamped(progress) } private var percentText: String { CircularProgressRingMath.percentText(progress) } private var strokeStyle: StrokeStyle { StrokeStyle(lineWidth: lineWidth, lineCap: .round) }
var body: some View { ZStack { Circle() .stroke(.quaternary, lineWidth: lineWidth)
ZStack { Circle() .trim(from: 0, to: value) .stroke(AngularGradient(colors: colors, center: .center), style: strokeStyle)
// The round cap at the start falls on the gradient's seam, so paint it with the first color. Circle() .trim(from: 0, to: value > 0 ? 0.001 : 0) .stroke(colors.first ?? .blue, style: strokeStyle) } // A round cap draws a dot even on a line with no length. .opacity(value > 0 ? 1 : 0) .rotationEffect(.degrees(-90))
Text(percentText) .font(font) .monospacedDigit() .contentTransition(.numericText(value: value)) .minimumScaleFactor(0.4) .lineLimit(1) .padding(lineWidth + 4) } .padding(lineWidth / 2) .aspectRatio(1, contentMode: .fit) .animation(reduceMotion ? nil : .smooth(duration: 0.7), value: value) .accessibilityElement() .accessibilityLabel(label) .accessibilityValue(percentText) }}
// MARK: - Demo
struct CircularProgressRingDemo: View { @State private var progress = 0.0 @ScaledMetric(relativeTo: .largeTitle) private var percentSize = 60
var body: some View { VStack(spacing: 40) { Text("Uploading photos") .font(.system(.title, design: .rounded, weight: .bold))
CircularProgressRing( progress: progress, lineWidth: 26, font: .system(size: percentSize, weight: .bold, design: .rounded), label: "Upload" ) .frame(width: 280)
HStack(spacing: 28) { CircularProgressRing(progress: 0.32, colors: [.pink, .orange], lineWidth: 8, font: .headline, label: "Move") CircularProgressRing(progress: 0.68, colors: [.green, .mint], lineWidth: 8, font: .headline, label: "Exercise") CircularProgressRing(progress: 0.91, colors: [.teal, .blue], lineWidth: 8, font: .headline, label: "Stand") } .frame(height: 88) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) .task { let values = [0.25, 0.6, 0.85, 1, 0] var index = 0 while !Task.isCancelled { progress = values[index] index = (index + 1) % values.count try? await Task.sleep(for: .seconds(0.9)) } } }}
#Preview { CircularProgressRingDemo()}