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.

RingSpinner.swiftplays as written
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.

DashSpinner.swiftplays as written
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.

BouncingBall.swiftplays as written
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).

TwinOrbitLoader.swiftplays as written
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. repeatForever animates a change. Without onAppear flipping the state once, there is no change to animate.
  • It stutters at the seam. That is autoreverses defaulting to true, winding the rotation back to zero. Set it to false.
  • Modifier order. rotationEffect then offset is not the same picture as offset then rotationEffect. 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.