SwiftUI
SwiftUI loading animation
SwiftUI has ProgressView, and for most screens that is the right
answer. These are for the times it isn't: when the spinner is part of the brand, or
has to match a colour, or has to be a shape that ProgressView will never
be.
All four are one file each. Nothing here needs a package.
The trimmed ring
This is the one people mean by "custom spinner". A Circle is a shape,
trim cuts its outline short, stroke draws what's left, and
rotating forever makes it spin.
import SwiftUI
struct RingSpinner: View {
@State private var turning = false
var size: Double = 46
var lineWidth: Double = 5
var tint: Color = .orange
var body: some View {
Circle()
.trim(from: 0, to: 0.72)
.stroke(tint, lineWidth: lineWidth)
.frame(width: size, height: size)
.rotationEffect(.degrees(turning ? 360 : 0))
.animation(.linear(duration: 1).repeatForever(autoreverses: false), value: turning)
.onAppear { turning = true }
}
}Two details do most of the work. autoreverses: false keeps it turning
one way instead of winding back, and .linear keeps the speed even — an
easing curve makes a spinner look like it is struggling.
Twelve fading dashes
The system spinner's real shape. Each dash is the same capsule, rotated a further 30° and one step further through the fade.
import SwiftUI
struct DashSpinner: View {
@State private var going = false
var count: Int = 12
var period: Double = 1.05
var body: some View {
ZStack {
ForEach(0..<count, id: \.self) { i in
Capsule()
.fill(.indigo)
.frame(width: 4, height: 13)
.offset(y: -17)
.rotationEffect(.degrees(Double(i) / Double(count) * 360))
.opacity(going ? 0.12 : 1)
.animation(
.linear(duration: period)
.repeatForever(autoreverses: false)
.delay(Double(i) * period / Double(count)),
value: going
)
}
}
.frame(width: 48, height: 48)
.onAppear { going = true }
}
}Note the order: offset then rotationEffect. Offsetting
first pushes the dash out to the radius, and the rotation then swings it round the
centre. Swap the two and all twelve dashes sit on top of each other.
A bouncing dot
When the wait is short, a bounce reads as friendlier than a spin. The overshoot is the whole point, so this one uses a spring rather than an easing curve.
import SwiftUI
struct BouncingBall: View {
@State private var down = false
var tint: Color = .pink
var body: some View {
ZStack(alignment: .top) {
Circle()
.fill(tint)
.frame(width: 18, height: 18)
.offset(y: down ? 28 : 0)
}
.frame(width: 44, height: 48)
.animation(
.spring(response: 0.36, dampingFraction: 0.55).repeatForever(),
value: down
)
.onAppear { down = true }
}
}An orbit, driven by the clock
The three above animate between two states. If you want motion that is a function
of time — a position computed from sin and cos rather than
interpolated between a start and an end — you need
TimelineView(.animation).
import SwiftUI
struct TwinOrbitLoader: View {
var size: Double = 62
var period: Double = 0.85
var body: some View {
TimelineView(.animation) { context in
let t = context.date.timeIntervalSinceReferenceDate
let a = t * .pi * 2 / period
ZStack {
ball(angle: a, color: .mint)
ball(angle: a + .pi, color: .cyan)
}
}
.frame(width: size, height: size)
}
func ball(angle: Double, color: Color) -> some View {
let depth = sin(angle)
return Circle()
.fill(color.gradient)
.frame(width: size * 0.36, height: size * 0.36)
.scaleEffect(0.78 + 0.22 * ((depth + 1) / 2))
.offset(x: cos(angle) * size * 0.28, y: sin(angle) * size * 0.1)
.zIndex(depth)
}
}zIndex(depth) is what sells it: the ball on the far side of the
orbit is drawn behind the near one, so the ring reads as a circle in space rather
than two dots sliding past each other.
Three things that go wrong
- The spinner never starts.
repeatForeveranimates a change. WithoutonAppearflipping the state once, there is no change to animate. - It stutters at the seam. That is
autoreversesdefaulting totrue, winding the rotation back to zero. Set it tofalse. - Modifier order.
rotationEffectthenoffsetis not the same picture asoffsetthenrotationEffect. Order is the layout, not a detail.
Try changing one
Paste any of these into Swoop and change a number while it runs.
Take DashSpinner's count from 12 to 6, or its
period from 1.05 to 0.4, and you will understand the delay-per-item
trick faster than by reading about it.
Keep reading
SwiftUI animation examples
Eight components, complete source for each, all running as you read.
2026-09-08withAnimation, and the modifier that replaced it
Two ways to animate the same change, and the rule for picking one.
2026-09-08SwiftUI rotation animation
rotationEffect, anchors, and the two mistakes everybody makes once.
2026-09-08