The Swift Kit logoThe Swift Kit
Tutorial

SwiftUI Animations Guide — From Basic Transitions to Advanced Motion Design

Everything you need to master SwiftUI animations: implicit and explicit techniques, spring physics, matched geometry hero transitions, iOS 17 phase and keyframe animators, gesture-driven motion, and real-world patterns you can drop into production today.

Ahmed GaganAhmed Gagan
12 min read

Why Animations Matter More Than You Think

Animations are not decoration. They are a critical part of how users understand your app. When a card slides in from the right, the user knows it came from somewhere and can swipe it back. When a button shrinks on tap, the user gets instant feedback that the touch registered. When a deleted row fades out, the user does not wonder where the data went. Remove those animations and your app feels broken, even when it is technically working.

Apple's Human Interface Guidelines emphasize this point directly: motion should communicate status, provide feedback, and help users visualize the results of their actions. In App Store editorial features, polished animations are consistently cited as a differentiator. The apps that get featured look and feel alive.

SwiftUI makes animation dramatically easier than UIKit ever did. In UIKit you would write a dozen lines inside UIView.animate(withDuration:) blocks, manage completion handlers, and coordinate multiple property changes manually. In SwiftUI, most animations are a single modifier or a single closure. But easy does not mean simple. There are at least six distinct animation APIs in SwiftUI, each designed for a different use case, and choosing the wrong one creates jank, visual glitches, or wasted CPU cycles.

This guide covers every major animation API in SwiftUI, from the basics through iOS 17's newest additions. Every section includes production-ready code you can copy directly into your project.

Implicit vs. Explicit Animations

SwiftUI has two fundamental approaches to animation, and understanding the difference is the single most important concept in this entire guide.

Implicit Animations with .animation()

An implicit animation watches a specific value and automatically animates any view changes that result from that value changing. You attach it with the .animation(_:value:) modifier:

struct ImplicitExample: View {
    @State private var isExpanded = false

    var body: some View {
        VStack(spacing: 20) {
            RoundedRectangle(cornerRadius: 16)
                .fill(.blue.gradient)
                .frame(
                    width: isExpanded ? 300 : 150,
                    height: isExpanded ? 200 : 100
                )
                .animation(.spring(duration: 0.5, bounce: 0.3), value: isExpanded)

            Button("Toggle") {
                isExpanded.toggle()
            }
        }
    }
}

Here, .animation(.spring(...), value: isExpanded) tells SwiftUI: whenever isExpanded changes, animate the frame change on this rectangle with a spring curve. The animation only fires when that specific value changes — not when other state changes. This is intentional and prevents accidental animations on unrelated updates.

Important: Always use the value: parameter. The deprecated .animation(.spring()) form (without a value) animates every state change, which causes unpredictable behavior in complex views. Apple deprecated it in iOS 15 for good reason.

Explicit Animations with withAnimation

An explicit animation wraps a state change in a withAnimation closure. Every view that depends on the changed state will animate:

struct ExplicitExample: View {
    @State private var isExpanded = false
    @State private var rotation: Double = 0

    var body: some View {
        VStack(spacing: 20) {
            RoundedRectangle(cornerRadius: 16)
                .fill(.purple.gradient)
                .frame(
                    width: isExpanded ? 300 : 150,
                    height: isExpanded ? 200 : 100
                )
                .rotationEffect(.degrees(rotation))

            Button("Animate") {
                withAnimation(.spring(duration: 0.6, bounce: 0.25)) {
                    isExpanded.toggle()
                    rotation += 90
                }
            }
        }
    }
}

With explicit animations, you control exactly when the animation happens by wrapping the state mutation. This is ideal when you need to animate multiple properties in sync — here, the frame and the rotation animate together with the same spring curve.

When to Use Each

  • Implicit (.animation) — best for isolated, self-contained view animations. A pulsing indicator, a color shift on hover, a single element expanding. The animation is tied to the view, not to the trigger.
  • Explicit (withAnimation) — best for coordinated state changes that affect multiple views. A navigation transition, a layout shift with multiple elements, toggling a mode that changes the entire screen.

In practice, most production apps use explicit animations for the majority of their interactions. Implicit animations shine for smaller, localized effects like loading indicators or hover feedback.

Animation Types and Timing Curves

SwiftUI ships with several built-in animation types. Each one defines how a value changes over time — the timing curve. Choosing the right curve is the difference between motion that feels natural and motion that feels robotic.

Animation TypeSyntaxBehaviorBest For
.linear.linear(duration: 0.3)Constant speed from start to finishProgress bars, spinning loaders, color cycling
.easeIn.easeIn(duration: 0.3)Starts slow, acceleratesElements leaving the screen, fade-outs
.easeOut.easeOut(duration: 0.3)Starts fast, deceleratesElements entering the screen, drop-ins
.easeInOut.easeInOut(duration: 0.3)Slow start, fast middle, slow endGeneral-purpose, default if you are unsure
.spring.spring(duration: 0.5, bounce: 0.3)Physics-based spring with optional overshootMost UI interactions, buttons, cards, toggles
.smooth.smooth(duration: 0.4)Spring with zero bounce (critically damped)Subtle movements, layout changes, sheets
.snappy.snappy(duration: 0.3)Fast spring with minimal bounceQuick feedback, tab switches, icon changes
.bouncy.bouncy(duration: 0.5)Spring with noticeable bouncePlayful UI, game elements, celebratory effects
.interpolatingSpring.interpolatingSpring(stiffness: 200, damping: 15)Fine-tuned spring with physics parametersCustom feel, brand-specific motion
Custom Bezier.timingCurve(0.2, 0.8, 0.2, 1, duration: 0.4)Custom cubic bezier curveMatching design tools (Figma, After Effects)

A practical rule of thumb: use spring animations for almost everything. They feel more natural than bezier curves because they model real-world physics. Apple's own iOS animations are predominantly spring-based. The .spring(duration: 0.5, bounce: 0.2) preset is an excellent starting point for most interactions.

// Custom spring with fine-tuned physics
withAnimation(.interpolatingSpring(
    stiffness: 180,
    damping: 14,
    initialVelocity: 0.5
)) {
    cardOffset = .zero
}

// Custom bezier matching a Figma prototype
withAnimation(.timingCurve(0.25, 0.1, 0.25, 1.0, duration: 0.35)) {
    showPanel = true
}

Built-In Transitions

Transitions control how views appear and disappear. When a view is conditionally inserted or removed from the hierarchy (via if statements or ternary expressions inside the view body), SwiftUI uses the .transition() modifier to animate the insertion and removal.

TransitionSyntaxEffectCommon Use Case
.opacity.transition(.opacity)Fades in/outOverlays, toasts, error messages
.slide.transition(.slide)Slides from leading edge, exits to trailingSide panels, navigation drawers
.scale.transition(.scale)Scales from zero to full sizePopups, FAB menus, modal cards
.move(edge:).transition(.move(edge: .bottom))Slides in from the specified edgeBottom sheets, banners, notifications
.push(from:).transition(.push(from: .trailing))Pushes in from one edge, pushes out from oppositeStep-by-step wizards, onboarding flows
.offset.transition(.offset(y: 50))Appears from a specific offset positionList items, staggered entry animations
.asymmetric.transition(.asymmetric(insertion: .scale, removal: .opacity))Different animation for insert vs. removeCards that pop in but fade out, complex flows
.combined.transition(.scale.combined(with: .opacity))Combines multiple transitionsRich entry effects, polished micro-interactions

Here is a practical example using .asymmetric for a notification banner that slides down from the top but fades out when dismissed:

struct NotificationBanner: View {
    @State private var showBanner = false

    var body: some View {
        ZStack(alignment: .top) {
            Color.clear // Background

            if showBanner {
                HStack(spacing: 12) {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundStyle(.green)
                    Text("Purchase successful!")
                        .font(.subheadline.weight(.medium))
                }
                .padding()
                .frame(maxWidth: .infinity)
                .background(.ultraThinMaterial)
                .clipShape(RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal)
                .transition(.asymmetric(
                    insertion: .move(edge: .top).combined(with: .opacity),
                    removal: .opacity
                ))
            }

            Button(showBanner ? "Dismiss" : "Show Banner") {
                withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                    showBanner.toggle()
                }
            }
            .padding(.top, 100)
        }
    }
}

matchedGeometryEffect for Hero Transitions

The matchedGeometryEffect modifier is one of SwiftUI's most powerful animation tools. It creates smooth hero transitions between two completely separate views by matching their geometry — position, size, and shape — across an animated state change.

The concept is simple: you give two views the same id within a shared @Namespace. When one view disappears and the other appears, SwiftUI automatically interpolates between their frames, creating a fluid morphing effect.

struct HeroTransitionDemo: View {
    @Namespace private var heroNamespace
    @State private var selectedCard: Int? = nil

    var body: some View {
        ZStack {
            // Grid of cards
            if selectedCard == nil {
                LazyVGrid(columns: [
                    GridItem(.flexible()),
                    GridItem(.flexible())
                ], spacing: 16) {
                    ForEach(0..<4) { index in
                        cardView(index: index)
                            .matchedGeometryEffect(
                                id: "card-\(index)",
                                in: heroNamespace
                            )
                            .onTapGesture {
                                withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                                    selectedCard = index
                                }
                            }
                    }
                }
                .padding()
            }

            // Expanded card detail
            if let selected = selectedCard {
                expandedCardView(index: selected)
                    .matchedGeometryEffect(
                        id: "card-\(selected)",
                        in: heroNamespace
                    )
                    .onTapGesture {
                        withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                            selectedCard = nil
                        }
                    }
                    .transition(.opacity)
            }
        }
    }

    func cardView(index: Int) -> some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(
                [Color.blue, .purple, .orange, .pink][index]
                    .gradient
            )
            .frame(height: 150)
            .overlay(
                Text("Card \(index + 1)")
                    .font(.headline)
                    .foregroundStyle(.white)
            )
    }

    func expandedCardView(index: Int) -> some View {
        RoundedRectangle(cornerRadius: 24)
            .fill(
                [Color.blue, .purple, .orange, .pink][index]
                    .gradient
            )
            .frame(maxWidth: .infinity, maxHeight: 400)
            .overlay(
                VStack(spacing: 12) {
                    Text("Card \(index + 1)")
                        .font(.largeTitle.bold())
                    Text("Tap to collapse")
                        .font(.subheadline)
                }
                .foregroundStyle(.white)
            )
            .padding()
    }
}

This is the foundation for App Store-style card expansions, photo gallery zoom-ins, and any transition where an element appears to morph from a thumbnail into a full-screen view. The key to making it smooth is ensuring both views share the same id and @Namespace, and wrapping the state change in withAnimation.

Performance tip: matchedGeometryEffect works best with simple shapes. If you match complex views with many subviews, SwiftUI may struggle to interpolate smoothly. Keep the matched element itself simple (a shape or image) and layer additional content on top with separate transitions.

PhaseAnimator (iOS 17+)

PhaseAnimator is Apple's answer to multi-step animations. Before iOS 17, sequencing animations required nesting withAnimation calls with DispatchQueue.main.asyncAfter delays — fragile and hard to maintain. PhaseAnimator defines a sequence of phases and automatically steps through them.

// A pulsing notification dot that cycles through phases
struct PulsingDot: View {
    var body: some View {
        PhaseAnimator([false, true]) { phase in
            Circle()
                .fill(.red)
                .frame(width: phase ? 14 : 10, height: phase ? 14 : 10)
                .opacity(phase ? 1.0 : 0.6)
                .scaleEffect(phase ? 1.2 : 1.0)
        } animation: { phase in
            phase ? .easeInOut(duration: 0.8) : .easeInOut(duration: 0.6)
        }
    }
}

// Multi-step loading animation
enum LoadingPhase: CaseIterable {
    case idle, scale, rotate, settle
}

struct AnimatedLoader: View {
    var body: some View {
        PhaseAnimator(LoadingPhase.allCases) { phase in
            Image(systemName: "arrow.trianglehead.2.clockwise")
                .font(.system(size: 32, weight: .medium))
                .rotationEffect(.degrees(phase == .rotate ? 360 : 0))
                .scaleEffect(phase == .scale ? 1.3 : 1.0)
                .opacity(phase == .idle ? 0.5 : 1.0)
                .foregroundStyle(.blue.gradient)
        } animation: { phase in
            switch phase {
            case .idle: .smooth(duration: 0.3)
            case .scale: .spring(duration: 0.4, bounce: 0.4)
            case .rotate: .linear(duration: 0.6)
            case .settle: .smooth(duration: 0.3)
            }
        }
    }
}

PhaseAnimator automatically loops through your phases. You can also use the trigger: parameter to start the animation sequence only when a specific value changes, rather than looping continuously:

// Triggered animation — plays once per state change
struct CelebrationBadge: View {
    @State private var triggerCount = 0

    var body: some View {
        VStack(spacing: 20) {
            PhaseAnimator(
                [false, true],
                trigger: triggerCount
            ) { phase in
                Image(systemName: "star.fill")
                    .font(.system(size: 48))
                    .foregroundStyle(.yellow)
                    .scaleEffect(phase ? 1.5 : 1.0)
                    .rotationEffect(.degrees(phase ? 15 : 0))
            } animation: { phase in
                phase
                    ? .spring(duration: 0.3, bounce: 0.5)
                    : .spring(duration: 0.4)
            }

            Button("Celebrate") {
                triggerCount += 1
            }
        }
    }
}

KeyframeAnimator (iOS 17+)

While PhaseAnimator moves through discrete states, KeyframeAnimator gives you timeline-level control. You define keyframes for individual properties, each with their own timing, and SwiftUI interpolates between them. This is the closest SwiftUI gets to After Effects-style motion design.

struct BouncingEntrance: View {
    @State private var trigger = false

    var body: some View {
        VStack(spacing: 30) {
            Text("Welcome!")
                .font(.largeTitle.bold())
                .keyframeAnimator(
                    initialValue: AnimationValues(),
                    trigger: trigger
                ) { content, value in
                    content
                        .scaleEffect(value.scale)
                        .offset(y: value.yOffset)
                        .opacity(value.opacity)
                } keyframes: { _ in
                    KeyframeTrack(\.scale) {
                        SpringKeyframe(1.2, duration: 0.3, spring: .bouncy)
                        SpringKeyframe(0.9, duration: 0.2, spring: .bouncy)
                        SpringKeyframe(1.0, duration: 0.3, spring: .smooth)
                    }
                    KeyframeTrack(\.yOffset) {
                        LinearKeyframe(-30, duration: 0.15)
                        SpringKeyframe(0, duration: 0.5, spring: .bouncy)
                    }
                    KeyframeTrack(\.opacity) {
                        LinearKeyframe(1.0, duration: 0.2)
                    }
                }

            Button("Replay") {
                trigger.toggle()
            }
        }
    }
}

struct AnimationValues {
    var scale: Double = 0.5
    var yOffset: Double = 50
    var opacity: Double = 0
}

The AnimationValues struct holds every property you want to animate. Each KeyframeTrack controls one property independently with its own timeline of keyframes. SwiftUI supports LinearKeyframe, SpringKeyframe, CubicKeyframe, and MoveKeyframe for different interpolation styles within each track.

KeyframeAnimator is ideal for complex entrance animations, celebration effects, and any motion that needs precise timing across multiple properties simultaneously.

Custom Transitions

The built-in transitions cover common cases, but sometimes you need something unique to your brand. SwiftUI lets you create fully custom transitions by conforming to the Transition protocol:

// A custom iris/circle-reveal transition
struct IrisTransition: Transition {
    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .clipShape(Circle())
            .scaleEffect(phase.isIdentity ? 1 : 0.01)
            .opacity(phase.isIdentity ? 1 : 0)
    }
}

extension AnyTransition {
    static var iris: AnyTransition {
        .modifier(
            active: IrisModifier(progress: 0),
            identity: IrisModifier(progress: 1)
        )
    }
}

struct IrisModifier: ViewModifier {
    var progress: CGFloat

    var animatableData: CGFloat {
        get { progress }
        set { progress = newValue }
    }

    func body(content: Content) -> some View {
        content
            .clipShape(Circle())
            .scaleEffect(progress)
            .opacity(progress)
    }
}

// Usage
struct IrisDemo: View {
    @State private var showImage = false

    var body: some View {
        VStack {
            if showImage {
                Image(systemName: "photo.fill")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 200, height: 200)
                    .transition(.iris)
            }

            Button("Toggle") {
                withAnimation(.spring(duration: 0.6)) {
                    showImage.toggle()
                }
            }
        }
    }
}

With the Transition protocol (iOS 17+), the phase parameter tells you whether the view is entering, at identity, or leaving. For earlier iOS versions, use AnyTransition.modifier(active:identity:) with an animatable ViewModifier, as shown above with IrisModifier.

Gesture-Driven Animations

The most polished apps connect animations directly to user gestures, creating a feeling of direct manipulation. In SwiftUI, this means combining DragGesture, MagnificationGesture, or RotationGesture with animated state changes.

struct SwipeableCard: View {
    @State private var offset: CGSize = .zero
    @State private var isDragging = false

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.blue.gradient)
            .frame(width: 300, height: 400)
            .overlay(
                VStack {
                    Text("Swipe Me")
                        .font(.title2.bold())
                        .foregroundStyle(.white)
                    Text("Drag left or right")
                        .foregroundStyle(.white.opacity(0.7))
                }
            )
            .offset(offset)
            .rotationEffect(
                .degrees(Double(offset.width) / 20)
            )
            .scaleEffect(isDragging ? 1.05 : 1.0)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                        isDragging = true
                    }
                    .onEnded { value in
                        isDragging = false
                        let swipeThreshold: CGFloat = 150

                        if abs(value.translation.width) > swipeThreshold {
                            // Swipe away
                            withAnimation(.easeIn(duration: 0.2)) {
                                offset = CGSize(
                                    width: value.translation.width > 0 ? 500 : -500,
                                    height: 0
                                )
                            }
                        } else {
                            // Snap back
                            withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
                                offset = .zero
                            }
                        }
                    }
            )
            .animation(.spring(duration: 0.3), value: isDragging)
    }
}

This pattern is the basis for Tinder-style card swiping, dismissible sheets, and drag-to-reorder interfaces. The key details: during the drag, the offset tracks the finger with no animation (instant feedback). On release, a spring animation either snaps the card back or flings it off screen. The subtle rotation based on horizontal offset adds polish.

AnimatableModifier for Custom Animatable Data

Sometimes you need to animate a value that SwiftUI does not know how to interpolate by default. The Animatable protocol and animatableData property let you teach SwiftUI how to animate any numeric value.

// Animated counter that smoothly rolls between numbers
struct AnimatedCounter: View, Animatable {
    var value: Double

    var animatableData: Double {
        get { value }
        set { value = newValue }
    }

    var body: some View {
        Text("\(Int(value))")
            .font(.system(size: 48, weight: .bold, design: .rounded))
            .foregroundStyle(.white)
            .contentTransition(.numericText())
    }
}

// Usage
struct CounterDemo: View {
    @State private var count: Double = 0

    var body: some View {
        VStack(spacing: 20) {
            AnimatedCounter(value: count)

            Button("Add 100") {
                withAnimation(.spring(duration: 0.8)) {
                    count += 100
                }
            }
        }
    }
}

// Animated progress ring using animatableData
struct ProgressRing: Shape {
    var progress: Double

    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func path(in rect: CGRect) -> Path {
        var path = Path()
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2
        let startAngle = Angle(degrees: -90)
        let endAngle = Angle(degrees: -90 + (360 * progress))

        path.addArc(
            center: center,
            radius: radius,
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: false
        )
        return path
    }
}

// Usage
struct RingDemo: View {
    @State private var progress: Double = 0

    var body: some View {
        ProgressRing(progress: progress)
            .stroke(.blue.gradient, style: StrokeStyle(
                lineWidth: 8,
                lineCap: .round
            ))
            .frame(width: 100, height: 100)
            .onAppear {
                withAnimation(.easeInOut(duration: 1.5)) {
                    progress = 0.75
                }
            }
    }
}

The animatableData property is the bridge between SwiftUI's animation system and your custom values. As long as the type conforms to VectorArithmetic (which Double, CGFloat, and AnimatablePair already do), SwiftUI can interpolate it smoothly over time.

Practical Example: Animated Onboarding

Let us build a real onboarding screen with staggered entrance animations. Each element enters with a delay, creating a cascading reveal effect:

struct AnimatedOnboarding: View {
    @State private var showContent = false
    @State private var currentPage = 0

    let pages = [
        OnboardingPage(
            icon: "wand.and.stars",
            title: "AI-Powered",
            subtitle: "Smart features that learn from you"
        ),
        OnboardingPage(
            icon: "lock.shield.fill",
            title: "Private & Secure",
            subtitle: "Your data stays on your device"
        ),
        OnboardingPage(
            icon: "bolt.fill",
            title: "Lightning Fast",
            subtitle: "Optimized for performance"
        ),
    ]

    var body: some View {
        VStack(spacing: 40) {
            Spacer()

            // Icon with bounce entrance
            Image(systemName: pages[currentPage].icon)
                .font(.system(size: 72))
                .foregroundStyle(.blue.gradient)
                .scaleEffect(showContent ? 1 : 0.3)
                .opacity(showContent ? 1 : 0)

            // Title with slide-up
            VStack(spacing: 12) {
                Text(pages[currentPage].title)
                    .font(.largeTitle.bold())
                    .offset(y: showContent ? 0 : 30)
                    .opacity(showContent ? 1 : 0)

                Text(pages[currentPage].subtitle)
                    .font(.title3)
                    .foregroundStyle(.secondary)
                    .offset(y: showContent ? 0 : 20)
                    .opacity(showContent ? 1 : 0)
            }

            Spacer()

            // Page dots
            HStack(spacing: 8) {
                ForEach(0..<pages.count, id: \.self) { index in
                    Circle()
                        .fill(index == currentPage ? .blue : .gray.opacity(0.3))
                        .frame(width: index == currentPage ? 24 : 8, height: 8)
                        .clipShape(Capsule())
                        .animation(.spring(duration: 0.4), value: currentPage)
                }
            }

            // Continue button
            Button {
                withAnimation(.smooth(duration: 0.3)) {
                    showContent = false
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    currentPage = (currentPage + 1) % pages.count
                    withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
                        showContent = true
                    }
                }
            } label: {
                Text("Continue")
                    .font(.headline)
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 16)
                    .background(.blue.gradient)
                    .foregroundStyle(.white)
                    .clipShape(RoundedRectangle(cornerRadius: 16))
            }
            .padding(.horizontal, 32)
            .padding(.bottom, 40)
        }
        .onAppear {
            withAnimation(.spring(duration: 0.8, bounce: 0.3)) {
                showContent = true
            }
        }
    }
}

struct OnboardingPage {
    let icon: String
    let title: String
    let subtitle: String
}

Practical Example: Loading Shimmer Effect

Skeleton loading screens with a shimmer effect tell users that content is loading. Here is a reusable shimmer modifier:

// Reusable shimmer modifier
struct ShimmerModifier: ViewModifier {
    @State private var phase: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .overlay(
                LinearGradient(
                    colors: [
                        .clear,
                        .white.opacity(0.2),
                        .clear
                    ],
                    startPoint: .leading,
                    endPoint: .trailing
                )
                .offset(x: phase)
                .mask(content)
            )
            .onAppear {
                withAnimation(
                    .linear(duration: 1.5)
                    .repeatForever(autoreverses: false)
                ) {
                    phase = 350
                }
            }
    }
}

extension View {
    func shimmer() -> some View {
        modifier(ShimmerModifier())
    }
}

// Usage: Skeleton loading screen
struct SkeletonLoadingView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            // Avatar + name row
            HStack(spacing: 12) {
                Circle()
                    .fill(Color.gray.opacity(0.3))
                    .frame(width: 48, height: 48)
                VStack(alignment: .leading, spacing: 6) {
                    RoundedRectangle(cornerRadius: 4)
                        .fill(Color.gray.opacity(0.3))
                        .frame(width: 120, height: 14)
                    RoundedRectangle(cornerRadius: 4)
                        .fill(Color.gray.opacity(0.3))
                        .frame(width: 80, height: 12)
                }
            }

            // Content lines
            ForEach(0..<3, id: \.self) { _ in
                RoundedRectangle(cornerRadius: 4)
                    .fill(Color.gray.opacity(0.3))
                    .frame(height: 14)
            }

            // Image placeholder
            RoundedRectangle(cornerRadius: 12)
                .fill(Color.gray.opacity(0.3))
                .frame(height: 200)
        }
        .shimmer()
        .padding()
    }
}

Practical Example: Button Feedback Animation

Micro-interactions on buttons give users instant feedback. Here is a production-quality button with scale, haptic feedback, and a success state:

struct AnimatedButton: View {
    @State private var isPressed = false
    @State private var isSuccess = false

    var body: some View {
        Button {
            // Trigger success animation
            withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
                isSuccess = true
            }
            // Haptic feedback
            let impact = UIImpactFeedbackGenerator(style: .medium)
            impact.impactOccurred()

            // Reset after delay
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
                withAnimation(.smooth(duration: 0.3)) {
                    isSuccess = false
                }
            }
        } label: {
            HStack(spacing: 8) {
                if isSuccess {
                    Image(systemName: "checkmark")
                        .transition(.scale.combined(with: .opacity))
                }
                Text(isSuccess ? "Done!" : "Submit")
                    .contentTransition(.numericText())
            }
            .font(.headline)
            .frame(maxWidth: .infinity)
            .padding(.vertical, 16)
            .background(isSuccess ? .green.gradient : .blue.gradient)
            .foregroundStyle(.white)
            .clipShape(RoundedRectangle(cornerRadius: 16))
            .scaleEffect(isPressed ? 0.95 : 1.0)
        }
        .buttonStyle(.plain)
        .padding(.horizontal, 32)
        ._onButtonGesture { pressing in
            withAnimation(.spring(duration: 0.2)) {
                isPressed = pressing
            }
        } perform: {}
    }
}

Practical Example: Card Flip Animation

A 3D card flip is a classic animation pattern for flash cards, reveal interactions, or settings toggles:

struct FlipCard: View {
    @State private var isFlipped = false
    @State private var rotation: Double = 0

    var body: some View {
        ZStack {
            // Front
            RoundedRectangle(cornerRadius: 20)
                .fill(.blue.gradient)
                .overlay(
                    VStack(spacing: 8) {
                        Image(systemName: "questionmark")
                            .font(.system(size: 48, weight: .bold))
                        Text("Tap to reveal")
                            .font(.headline)
                    }
                    .foregroundStyle(.white)
                )
                .opacity(isFlipped ? 0 : 1)

            // Back
            RoundedRectangle(cornerRadius: 20)
                .fill(.purple.gradient)
                .overlay(
                    VStack(spacing: 8) {
                        Image(systemName: "star.fill")
                            .font(.system(size: 48, weight: .bold))
                        Text("SwiftUI is amazing!")
                            .font(.headline)
                    }
                    .foregroundStyle(.white)
                )
                .opacity(isFlipped ? 1 : 0)
                .scaleEffect(x: -1) // Mirror text so it reads correctly
        }
        .frame(width: 280, height: 380)
        .rotation3DEffect(
            .degrees(rotation),
            axis: (x: 0, y: 1, z: 0),
            perspective: 0.5
        )
        .onTapGesture {
            withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
                rotation += 180
                isFlipped.toggle()
            }
        }
        .shadow(
            color: .black.opacity(0.2),
            radius: 16, x: 0, y: 8
        )
    }
}

Combining Animations

Real-world animations rarely use a single technique. The most polished effects combine multiple approaches. Here are the key combination patterns:

  • Staggered delays: Use .animation(.spring().delay(Double(index) * 0.05), value: trigger) on list items to create a cascading wave effect when content loads.
  • Chained withAnimation blocks: Nest DispatchQueue.main.asyncAfter calls with different withAnimation curves to sequence multi-step animations (exit, update, enter).
  • Matched geometry + transitions: Pair matchedGeometryEffect for the main element with separate .transition(.opacity) modifiers for supporting elements like text and buttons.
  • Gesture + spring: Track the gesture directly during interaction (.onChanged), then apply a spring on release (.onEnded) for that elastic snap-back feel.
  • PhaseAnimator for looping + KeyframeAnimator for triggered: Use PhaseAnimator for ambient background animations (pulsing, breathing) and KeyframeAnimator for one-shot triggered effects (success, error, celebration).

Performance Considerations

Animations that look great but drop frames are worse than no animation at all. Here are the rules for keeping your animations at a smooth 60fps (or 120fps on ProMotion devices):

  1. Animate transforms, not layout. Properties like .scaleEffect, .rotationEffect, .offset, and .opacity are GPU-accelerated and nearly free. Properties that trigger layout recalculations — .frame(), .padding(), conditional views — are more expensive. Prefer transforms whenever possible.
  2. Avoid animating in body recomputations. If your animation causes the view body to recompute on every frame, performance drops. Use .drawingGroup() to flatten complex view hierarchies into a single rendered layer.
  3. Use .drawingGroup() for complex overlapping shapes. When you have many layers of gradients, shadows, and blurs animating simultaneously, .drawingGroup() rasterizes the view into a Metal texture, dramatically improving performance.
  4. Limit matchedGeometryEffect scope. Do not apply it to dozens of views simultaneously. Match only the primary element (image, card background) and use separate, simpler transitions for secondary content.
  5. Profile with Instruments. Use the SwiftUI Instruments template in Xcode to identify which views are being redrawn during animations. The “View Body” instrument shows exactly how many times each view's body is called per frame.
  6. Prefer .contentTransition(.numericText()) over conditionally swapping Text views for animated number changes. It is optimized internally and avoids full view replacement.
Quick test: Enable Debug > Slow Animations in the iOS Simulator (Cmd + T) to see your animations at one-fifth speed. Jank that is invisible at normal speed becomes obvious when slowed down.

Animation Debugging Tips

When an animation does not behave as expected, here is a systematic debugging checklist:

  • Is the state change wrapped in withAnimation? If you are using explicit animations, every state mutation that should animate must be inside the closure. State changes outside the closure will snap instantly.
  • Are you using .animation(_:value:) with the correct value? If the value you pass does not actually change, the animation never fires. Double-check that the value parameter matches the state driving the visual change.
  • Is the view being removed and re-inserted instead of updated? If your conditional logic creates a new view identity (different id, different position in the view tree), SwiftUI treats it as a removal + insertion, not an update. Transitions fire instead of property animations.
  • Check for conflicting animations. If both .animation() and withAnimation affect the same property, the behavior can be unpredictable. Pick one approach per property.
  • Use .transaction to inspect animation state. The .transaction modifier lets you log or override the current animation context, which helps identify where animations are being applied or overridden.

Ship Polished Animations Without Starting from Scratch

Every animation pattern covered in this guide — spring-driven onboarding transitions, shimmer loading states, button feedback, hero matched geometry effects — requires careful tuning to feel right. Getting the spring stiffness, duration, and bounce dialed in for each interaction takes hours of iteration.

The Swift Kit ships with production-polished animations baked into every component. The onboarding flow uses staggered spring entrances across three customizable templates. The paywall uses a matched geometry hero transition for plan selection with a bounce that feels native. Loading states include shimmer skeletons. Buttons have scale feedback with haptic integration. Every animation has been tested on devices ranging from iPhone SE to iPhone 16 Pro Max to ensure consistent 60fps performance.

Instead of building and tuning these animation patterns from scratch, you can start with a codebase where every interaction already feels polished. Head over to the pricing page to see what is included, or explore the features overview to see the animations in action. Every animation in the kit uses the techniques from this guide — so you will understand exactly how they work and can customize them to match your brand.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit