Building a Breathing Animation in SwiftUI
The smallest animations make the biggest difference. Here’s how to build one that feels alive.
Building a Breathing Animation in SwiftUI
Photo by Microsoft Copilot on Unsplash
The smallest animations make the biggest difference. Here’s how to build one that feels alive.
— -
There’s a moment in every iOS app where nothing is happening — a loading state, a waiting screen, a recording indicator — and instead of showing a static element, the best apps show something that breathes.
You’ve seen it in Apple’s own apps. The AirPods animation. The live activity pulse. The way the microphone indicator gently expands and contracts when Siri is listening. It communicates “I’m alive, I’m waiting, everything is fine” without a single word.
Today we’re building that effect from scratch in SwiftUI. Four variations, progressively more polished. By the end you’ll have a reusable component you can drop anywhere.
Let’s get into it.
— -
The Core Idea
A breathing animation is just two properties animating in sync — scaleEffect and opacity — repeating forever, reversing direction each cycle. SwiftUI makes this surprisingly clean.
struct BreathingCircle: View {
@State private var isBreathing = false
var body: some View {
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.scaleEffect(isBreathing ? 1.2 : 1.0)
.opacity(isBreathing ? 0.6 : 1.0)
.animation(
.easeInOut(duration: 1.5).repeatForever(autoreverses: true),
value: isBreathing
)
.onAppear {
isBreathing = true
}
}
}
That’s it. Flip isBreathing to true on appear, and SwiftUI handles the rest.
The autoreverses: true is doing the heavy lifting here — it smoothly oscillates between your two states instead of snapping back. The easeInOut curve gives it that organic, lung-like rhythm.
— -
Variation 1 — Layered Rings
A single circle breathing is fine. Multiple rings breathing at offset intervals looks like a sonar pulse — and it’s only a few more lines.
struct PulsingRings: View {
@State private var animate = false
var body: some View {
ZStack {
ForEach(0..<3) { index in
Circle()
.stroke(Color.blue.opacity(0.4), lineWidth: 2)
.frame(width: 80, height: 80)
.scaleEffect(animate ? 2.5 : 1.0)
.opacity(animate ? 0 : 0.8)
.animation(
.easeOut(duration: 2.0)
.repeatForever(autoreverses: false)
.delay(Double(index) * 0.6),
value: animate
)
}
Circle()
.fill(Color.blue)
.frame(width: 50, height: 50)
}
.onAppear {
animate = true
}
}
}
#Preview(body: {
PulsingRings()
})
The trick is the .delay(Double(index) * 0.6)— each ring starts slightly later than the one before, creating the ripple cascade. autoreverses: false lets the rings fully expand and disappear before
looping, which gives you that clean sonar feel.
Use this for: live recording indicators, active location pins, incoming call screens.
— -
Variation 2 — Breathing Button
Animations become genuinely useful when they’re attached to something interactive. Here’s a button that breathes while it’s in a loading state and snaps solid when it’s done.
struct BreathingButton: View {
@State private var isLoading = false
@State private var breathe = false
var body: some View {
Button(action: {
isLoading = true
breathe = true
// Simulate async work
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
isLoading = false
breathe = false
}
}) {
Text(isLoading ? "Processing…" : "Confirm Payment")
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.horizontal, 32)
.padding(.vertical, 16)
.background(Color.blue)
.clipShape(Capsule())
.scaleEffect(breathe ? 1.04 : 1.0)
.opacity(breathe ? 0.85 : 1.0)
.animation(
breathe
? .easeInOut(duration: 0.9).repeatForever(autoreverses: true)
: .easeOut(duration: 0.2),
value: breathe
)
}
.disabled(isLoading)
}
}
#Preview(body: {
BreathingButton()
})
Notice the conditional animation — when breathe is false, you get a quick .easeOut snap back to normal instead of continuing the loop. This is an important detail. If you just set breathe = false, the animation jumps. The conditional gives it a clean exit.
— -
Variation 3 — Glow Breath
Add a colored shadow to the mix and the breathing effect suddenly feels more premium. This works especially well on dark backgrounds.
struct GlowingBreath: View {
@State private var glow = false
var body: some View {
Circle()
.fill(Color.purple)
.frame(width: 80, height: 80)
.shadow(
color: Color.purple.opacity(glow ? 0.8 : 0.2),
radius: glow ? 30 : 8
)
.scaleEffect(glow ? 1.1 : 1.0)
.animation(
.easeInOut(duration: 2.0).repeatForever(autoreverses: true),
value: glow
)
.onAppear {
glow = true
}
}
}
#Preview("GlowingBreath", body: {
GlowingBreath()
})
The shadow radius expanding from 8 to 30 while the scale grows just slightly creates the illusion that the circle is emitting light. No blur effects, no custom shaders — just a shadow and a scale.
— -
Making It Reusable
Once you’re using this in more than one screen, wrap it:
struct BreathingIndicator: View {
var color: Color = .blue
var size: CGFloat = 60
var duration: Double = 1.5
@State private var breathe = false
var body: some View {
Circle()
.fill(color)
.frame(width: size, height: size)
.scaleEffect(breathe ? 1.15 : 1.0)
.opacity(breathe ? 0.65 : 1.0)
.animation(
.easeInOut(duration: duration).repeatForever(autoreverses: true),
value: breathe
)
.onAppear { breathe = true }
}
}
#Preview(body: {
BreathingIndicator()
})
Now you drop BreathingIndicator(color: .green, size: 40) anywhere and it just works.
— -
A Note on Performance
SwiftUI’s animation system handles repeating animations efficiently — it doesn’t redraw the view every frame from scratch. But if you’re stacking many animated views in a List or ScrollView, add .drawingGroup() to the animated view to push rendering to Metal:
.drawingGroup()
One line. Significant difference on older devices when you have five or more simultaneous animations.
— -
Wrapping Up
Breathing animations are one of those small details that separate apps that feel built from apps that feel assembled. They cost almost nothing to implement and communicate aliveness in a way that
static spinners never do.
Here’s what we covered today:
-
Basic breathing with scaleEffect + opacity + repeatForever
-
Layered ripple rings with staggered delay
-
A breathing button that snaps clean on completion
-
Glow breath with shadow radius animation
-
A reusable BreathingIndicator component
-
.drawingGroup()for performance when scaling up
Try dropping the GlowingBreath variant on your next dark-mode loading screen. I promise you’ll keep it.
If you want me to cover the next step — breathing combined with haptic feedback so the phone literally pulses in your hand — drop a comment and I’ll make that Part 2.
— -
Follow for more SwiftUI deep dives every week. Next up: Custom Tab Bar with Spring Animations.
메타데이터
- post_id
- f7b6b506a01e
- slug
- building-a-breathing-animation-in-swiftui-f7b6b506a01e
- url
- https://medium.com/@iamvishal16/building-a-breathing-animation-in-swiftui-f7b6b506a01e
- canonical_url
- https://medium.com/@iamvishal16/building-a-breathing-animation-in-swiftui-f7b6b506a01e
- author_url
- https://medium.com/@iamvishal16
- status
- ok
- fetched_at
- 2026-06-10 18:44:10