Skip to content

Dots Pulse Loader

Three dots that pulse in sequence, as in a typing indicator.

Dots Pulse Loader, playing
Download .swift
Origin
Original work
Found by
loader · dots · pulse · typing indicator · chat · loading · cargador · puntos · pulso · escribiendo · chat · carga

Dots that pulse one after the other.

With Reduce Motion on, the dots stay still.

struct DotsPulseLoaderDemo: View {
var body: some View {
VStack(spacing: 48) {
DotsPulseLoader(color: .indigo, dotSize: 44)
VStack(alignment: .leading, spacing: 10) {
Text("Did the package arrive?")
.font(.body)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.blue.gradient, in: .rect(cornerRadius: 20))
.frame(maxWidth: .infinity, alignment: .trailing)
DotsPulseLoader(color: .secondary, dotSize: 10)
.padding(.horizontal, 18)
.padding(.vertical, 16)
.background(Color(.secondarySystemBackground), in: .rect(cornerRadius: 20))
}
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
ParameterTypeDefault
colorColor.indigo
dotCountInt3
dotSizeCGFloat14

Copy this code into your project, or download the file.

DotsPulseLoader.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// Dots that pulse one after the other.
///
/// With Reduce Motion on, the dots stay still.
struct DotsPulseLoader: View {
var color: Color = .indigo
var dotCount: Int = 3
var dotSize: CGFloat = 14
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Group {
if reduceMotion {
dots(activeIndex: nil)
} else {
PhaseAnimator(Array(0..<max(dotCount, 2))) { index in
dots(activeIndex: index)
} animation: { _ in
.easeInOut(duration: 0.32)
}
}
}
.accessibilityElement()
.accessibilityLabel("Loading")
}
private func dots(activeIndex: Int?) -> some View {
HStack(spacing: dotSize * 0.6) {
ForEach(0..<dotCount, id: \.self) { index in
Circle()
.fill(color.gradient)
.frame(width: dotSize, height: dotSize)
.scaleEffect(scale(index: index, activeIndex: activeIndex))
.opacity(index == activeIndex || activeIndex == nil ? 1 : 0.4)
}
}
}
private func scale(index: Int, activeIndex: Int?) -> CGFloat {
guard let activeIndex else { return 0.85 }
return index == activeIndex ? 1.15 : 0.7
}
}
// MARK: - Demo
struct DotsPulseLoaderDemo: View {
var body: some View {
VStack(spacing: 48) {
DotsPulseLoader(color: .indigo, dotSize: 44)
VStack(alignment: .leading, spacing: 10) {
Text("Did the package arrive?")
.font(.body)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.blue.gradient, in: .rect(cornerRadius: 20))
.frame(maxWidth: .infinity, alignment: .trailing)
DotsPulseLoader(color: .secondary, dotSize: 10)
.padding(.horizontal, 18)
.padding(.vertical, 16)
.background(Color(.secondarySystemBackground), in: .rect(cornerRadius: 20))
}
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#Preview {
DotsPulseLoaderDemo()
}