- Published on
- 8 min read Intermediate
> SwiftUI Animations: From Basics to KeyframeAnimator
// What_You_Will_Learn
- Choose between implicit and explicit animations for different use cases
- Use spring animations with the right presets for natural-feeling motion
- Build multi-step animations with PhaseAnimator
- Create choreographed effects with KeyframeAnimator
SwiftUI makes animation surprisingly approachable. Add a single modifier and your view springs to life. But the framework goes much deeper than basic transitions, with APIs for multi-step sequences, independent property timelines, and physics-based springs that feel natural under your fingers.
Implicit vs. Explicit Animations
SwiftUI offers two ways to animate changes. Implicit animations attach directly to a view with the .animation modifier:
struct PulsingDot: View {
@State private var isActive = false
var body: some View {
Circle()
.fill(isActive ? Color.green : Color.gray)
.frame(width: 20, height: 20)
.scaleEffect(isActive ? 1.2 : 1.0)
.animation(.easeInOut(duration: 0.3), value: isActive)
.onTapGesture {
isActive.toggle()
}
}
}
The value parameter is required and scopes the animation to changes in that specific value. The older form without value is deprecated because it caused unpredictable behavior when multiple state changes happened simultaneously.
Explicit animations wrap a state change with withAnimation, giving you control over exactly which mutation triggers the animation:
struct CardFlip: View {
@State private var isFlipped = false
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(isFlipped ? Color.blue : Color.orange)
.frame(width: 200, height: 300)
.rotation3DEffect(
.degrees(isFlipped ? 180 : 0),
axis: (x: 0, y: 1, z: 0)
)
.onTapGesture {
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
isFlipped.toggle()
}
}
}
}
Use implicit animations when a view should always animate a particular property change. Use withAnimation when you want to coordinate multiple state changes or when the same property should sometimes animate and sometimes not.
Spring Animations
Apple recommends springs as the default animation type because they produce natural-feeling motion. Since iOS 17, the spring API is straightforward:
.animation(.spring(duration: 0.5, bounce: 0.3), value: isExpanded)
The duration controls how long the spring takes to settle, and bounce ranges from 0 (no overshoot) to 1 (maximum bounciness). SwiftUI also provides three convenient presets:
struct SpringComparison: View {
@State private var offset: CGFloat = 0
var body: some View {
VStack(spacing: 40) {
Circle()
.frame(width: 40, height: 40)
.offset(x: offset)
.animation(.smooth, value: offset)
Circle()
.frame(width: 40, height: 40)
.offset(x: offset)
.animation(.snappy, value: offset)
Circle()
.frame(width: 40, height: 40)
.offset(x: offset)
.animation(.bouncy, value: offset)
}
.onTapGesture {
offset = offset == 0 ? 100 : 0
}
}
}
.smooth has no bounce and settles quickly. .snappy has a tiny bit of overshoot for a responsive feel. .bouncy has noticeable overshoot for playful interactions. For most UI animations, .snappy or .smooth is the right choice.
Animation Completion Callbacks
iOS 17 added completion handlers to withAnimation, which is useful for chaining animations or triggering follow-up logic:
withAnimation(.spring(duration: 0.4)) {
cardOffset = 300
} completion: {
withAnimation(.spring(duration: 0.3)) {
cardOffset = 0
showConfirmation = true
}
}
This replaces the old pattern of using DispatchQueue.main.asyncAfter to approximate animation timing, which was fragile and could fall out of sync.
Custom Transitions
SwiftUI provides built-in transitions like .opacity, .slide, .scale, and .move(edge:) for views that appear and disappear:
struct NotificationBanner: View {
@State private var showBanner = false
var body: some View {
VStack {
if showBanner {
Text("Saved successfully")
.padding()
.background(Color.green.opacity(0.9))
.cornerRadius(10)
.transition(.move(edge: .top).combined(with: .opacity))
}
Spacer()
Button("Save") {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
showBanner = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
showBanner = false
}
}
}
}
}
}
For more control, the Transition protocol (iOS 17+) lets you build custom transitions using view modifiers and TransitionPhase:
struct SlideAndFade: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.opacity(phase.isIdentity ? 1 : 0)
.offset(y: phase == .willAppear ? -20 : phase == .didDisappear ? 20 : 0)
.scaleEffect(phase.isIdentity ? 1 : 0.95)
}
}
// Usage
if showCard {
CardView()
.transition(SlideAndFade())
}
matchedGeometryEffect
For hero-style transitions where one view transforms into another, matchedGeometryEffect synchronizes the geometry between two views:
struct HeroTransition: View {
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
VStack {
if isExpanded {
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.matchedGeometryEffect(id: "card", in: animation)
.frame(height: 400)
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
isExpanded = false
}
}
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 150, height: 100)
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
isExpanded = true
}
}
}
}
}
}
The @Namespace provides a coordination space, and matching id values on both views tells SwiftUI to interpolate between their frames during the transition.
PhaseAnimator
PhaseAnimator (iOS 17+) drives multi-step animation sequences by cycling through a collection of phases. Each phase defines a different visual state, and SwiftUI animates between them automatically:
enum BouncePhase: CaseIterable {
case rest, up, down
var yOffset: Double {
switch self {
case .rest: 0
case .up: -30
case .down: 5
}
}
var scale: Double {
switch self {
case .rest: 1.0
case .up: 1.1
case .down: 0.95
}
}
}
struct BouncingEmoji: View {
@State private var trigger = 0
var body: some View {
Text("🏀")
.font(.system(size: 60))
.phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in
content
.offset(y: phase.yOffset)
.scaleEffect(phase.scale)
} animation: { phase in
switch phase {
case .rest: .spring(duration: 0.4, bounce: 0.5)
case .up: .easeOut(duration: 0.2)
case .down: .spring(duration: 0.3, bounce: 0.6)
}
}
.onTapGesture { trigger += 1 }
}
}
Without a trigger, the animator loops continuously, which is useful for loading indicators or attention-grabbing effects. With a trigger, it plays through all phases once each time the trigger value changes.
KeyframeAnimator
KeyframeAnimator (iOS 17+) provides timeline-based control where each property animates independently on its own track. This is the tool for complex, choreographed animations:
struct ShakeValues {
var xOffset: Double = 0
var rotation: Double = 0
var scale: Double = 1.0
}
struct ShakeButton: View {
@State private var trigger = 0
var body: some View {
Button("Submit") {
trigger += 1
}
.padding(.horizontal, 32)
.padding(.vertical, 12)
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(10)
.keyframeAnimator(initialValue: ShakeValues(), trigger: trigger) { content, value in
content
.offset(x: value.xOffset)
.rotationEffect(.degrees(value.rotation))
.scaleEffect(value.scale)
} keyframes: { _ in
KeyframeTrack(\.xOffset) {
SpringKeyframe(-10, duration: 0.1, spring: .snappy)
SpringKeyframe(10, duration: 0.1, spring: .snappy)
SpringKeyframe(-6, duration: 0.1, spring: .snappy)
SpringKeyframe(6, duration: 0.1, spring: .snappy)
SpringKeyframe(0, duration: 0.15, spring: .bouncy)
}
KeyframeTrack(\.rotation) {
CubicKeyframe(-3, duration: 0.1)
CubicKeyframe(3, duration: 0.1)
CubicKeyframe(-2, duration: 0.1)
CubicKeyframe(2, duration: 0.1)
CubicKeyframe(0, duration: 0.15)
}
KeyframeTrack(\.scale) {
CubicKeyframe(0.95, duration: 0.05)
CubicKeyframe(1.05, duration: 0.2)
SpringKeyframe(1.0, duration: 0.3, spring: .bouncy)
}
}
}
}
Each KeyframeTrack targets a property on the values struct. The keyframe types control interpolation: CubicKeyframe for smooth Bezier curves, SpringKeyframe for physics-based motion, LinearKeyframe for constant-speed transitions, and MoveKeyframe for instant jumps without animation.
The key difference from PhaseAnimator is that properties animate independently. The x offset might be bouncing back and forth while the scale is still settling, creating layered, organic-feeling motion.
Animating Numeric Text
For counters, scores, or any changing numbers, contentTransition(.numericText()) produces smooth digit transitions:
struct ScoreView: View {
@State private var score = 0
var body: some View {
VStack {
Text("\(score)")
.font(.system(size: 48, weight: .bold, design: .rounded))
.contentTransition(.numericText(value: Double(score)))
Button("Add Point") {
withAnimation(.spring(duration: 0.3)) {
score += 1
}
}
}
}
}
Individual digits roll in and out as the number changes, giving a polished feel without any custom animation code.
Choosing the Right API
For a simple toggle or state change, use withAnimation or .animation(_:value:). These cover the vast majority of animation needs in typical apps.
For multi-step sequences where all properties change together between states, use PhaseAnimator. Think loading indicators, celebration effects, or attention pulses.
For choreographed animations where different properties need independent timing, use KeyframeAnimator. This is your tool for complex motion design like error shakes, onboarding sequences, or game-like effects.
For view insertions and removals, use transition() with the built-in options or the Transition protocol for custom effects.
For hero-style morphing between views, use matchedGeometryEffect with a shared @Namespace.
The animation system in SwiftUI is layered. Start with the simplest API that does what you need, and reach for the more powerful tools when you need independent control over timing and properties.
// Frequently_Asked
// Continue_Learning
How to Change the Background Color of a View in SwiftUI
Learn different ways to set background colors on SwiftUI views, from simple color fills to gradients and materials.
SwiftUI vs UIKit: Which Should You Choose in 2026?
A practical guide to choosing between SwiftUI and UIKit in 2026, based on your project requirements, team experience, and the current state of both frameworks.
NavigationView vs NavigationStack: What Changed and Why
NavigationStack replaced NavigationView in iOS 16 with a more powerful programmatic navigation model. Here's what changed and how to migrate.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.