SwiftUI Animations: Springs, Keyframes, and Custom Transitions

Learn how to build polished SwiftUI animations — from springs and timing curves to PhaseAnimator, KeyframeAnimator, hero transitions, and the new @Animatable macro in iOS 26. Practical code examples included.

Animations can make or break a user interface. A static screen feels lifeless, but add some well-crafted motion and suddenly everything clicks — buttons respond, transitions flow, and the whole experience just feels right. SwiftUI makes this surprisingly approachable because the animation system is baked right into the declarative view hierarchy.

Whether you're building subtle tap feedback, orchestrating a multi-step onboarding flow, or crafting those satisfying hero transitions between screens, SwiftUI's got you covered. And honestly, the amount of polish you can achieve with relatively little code still impresses me.

This guide walks through every layer of the animation system — from basic implicit and explicit animations, through spring physics and timing curves, all the way to PhaseAnimator, KeyframeAnimator, matched geometry transitions, and the brand-new @Animatable macro in iOS 26. Each section includes practical code you can drop straight into your project.

How SwiftUI Animations Work Under the Hood

Every SwiftUI animation follows the same fundamental pattern: a state change triggers a view update, and SwiftUI interpolates the affected visual properties between their old and new values over a specified duration curve. The framework figures out which properties changed — position, opacity, scale, color, rotation — and generates intermediate frames automatically.

If you're coming from Core Animation, this is a pretty big shift. There's no manually describing keyframes or wrapping things in begin/commit animation blocks. You just declare the start and end states, and SwiftUI handles everything in between.

This declarative model is powerful, but it can also be surprising at times. So understanding the two fundamental approaches — implicit and explicit animation — is essential before reaching for the fancier tools.

Implicit Animations with the .animation() Modifier

An implicit animation tells a view "whenever this value changes, animate the transition." You attach an .animation() modifier along with the value you want to watch, and SwiftUI automatically animates any visual change when that value updates.

struct PulsingCircle: View {
    @State private var isActive = false

    var body: some View {
        Circle()
            .fill(isActive ? .blue : .gray)
            .frame(width: isActive ? 120 : 80, height: isActive ? 120 : 80)
            .animation(.easeInOut(duration: 0.4), value: isActive)
            .onTapGesture {
                isActive.toggle()
            }
    }
}

When isActive toggles, SwiftUI animates both the fill color and frame size simultaneously using the easeInOut curve. That value: parameter is critical — it tells SwiftUI which state change should trigger the animation. Without it, unrelated state changes could accidentally trigger animations on this view. (I've been bitten by this more than once.)

Scoped Animations in iOS 17+

Starting in iOS 17, you can use the .animation(_:body:) modifier to scope exactly which properties animate, without affecting child views:

Circle()
    .animation(.spring) {
        $0.scaleEffect(isActive ? 1.5 : 1.0)
    }

This ensures only the scale effect animates with the spring, even if other properties on the same view change at the same time. Super handy for complex views where you don't want animation "leaking" to unrelated properties.

Explicit Animations with withAnimation

Explicit animations take a different approach. Instead of telling a specific view how to animate, you wrap a state mutation inside a withAnimation closure and SwiftUI animates every visual change resulting from that state change.

struct CardFlip: View {
    @State private var isFlipped = false

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(isFlipped ? .green : .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()
                }
            }
    }
}

Explicit animations really shine when a single state change should animate multiple views throughout your hierarchy. Every view that reads the mutated state will animate, no matter where it sits in the view tree.

When to Use Implicit vs. Explicit

Use implicit animations when a specific view should always animate in a particular way for a particular value change. Use explicit animations when a user action should animate changes across multiple views, or when you want centralized control over what gets animated.

One thing to keep in mind: implicit animations on a child view take priority over explicit ones from a parent. If a view has its own .animation() modifier, that wins over whatever withAnimation specified at the call site.

Animation Curves and Timing

SwiftUI ships with several built-in timing curves that control how animation speed varies over time:

  • .linear — Constant speed from start to finish. Best for progress indicators and spinners.
  • .easeIn — Starts slow, accelerates toward the end. Good for elements entering the scene.
  • .easeOut — Starts fast, decelerates at the end. Natural for elements settling into position.
  • .easeInOut — Slow start and end with acceleration in the middle. This is the default feel for most transitions.

Each one can be customized with a duration:

.animation(.easeInOut(duration: 0.5), value: someValue)

You can also chain modifiers to add delays or repetition:

.animation(
    .easeInOut(duration: 0.8)
        .delay(0.2)
        .repeatCount(3, autoreverses: true),
    value: isAnimating
)

For continuous looping animations — like a breathing effect or a pulsing ring — use .repeatForever(autoreverses: true). Trigger it in .onAppear so it starts as soon as the view shows up.

Spring Animations: The Foundation of Natural Motion

Spring animations are, in my opinion, the single most important animation type to master in SwiftUI. They simulate physical spring mechanics, producing motion that overshoots the target slightly before settling — just like a real object on a spring. That subtle overshoot is what makes interactive UIs feel alive instead of robotic.

Basic Spring Usage

withAnimation(.spring()) {
    offset = CGSize(width: 0, height: -200)
}

Calling .spring() with no parameters gives you a sensible default. For more control, iOS 17 introduced a cleaner API using duration and bounce:

withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
    scale = 1.2
}

duration controls the perceived animation length, while bounce ranges from 0 (no overshoot — basically a critically damped spring) to 1 (maximum oscillation). A bounce around 0.2–0.4 feels natural for most interactive elements.

Built-in Spring Presets

iOS 17 also introduced three convenient presets that cover the most common scenarios:

  • .smooth — No bounce at all. Critically damped. Perfect for subtle transitions where you want natural deceleration without any overshoot.
  • .snappy — A small amount of bounce that gives interactions a responsive, crisp feel. Great for button taps and toggle switches.
  • .bouncy — Noticeable bounce for playful, attention-grabbing motion. Think onboarding animations or celebratory effects.
// Each preset can also be customized with duration
withAnimation(.bouncy(duration: 0.8)) {
    showConfetti = true
}

Interactive Springs for Gestures

When you're driving animations from gestures — drags, pinches, long presses — use .interactiveSpring(). This variant has a lower response time, so the view tracks your finger closely during the gesture and settles smoothly when you let go:

struct DraggableCard: View {
    @State private var dragOffset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.blue.gradient)
            .frame(width: 200, height: 280)
            .offset(dragOffset)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        dragOffset = value.translation
                    }
                    .onEnded { _ in
                        withAnimation(.interactiveSpring(
                            response: 0.4,
                            dampingFraction: 0.7
                        )) {
                            dragOffset = .zero
                        }
                    }
            )
    }
}

Custom Transitions

Transitions control how views appear and disappear. SwiftUI gives you built-ins like .opacity, .slide, .scale, and .move(edge:), but the real fun starts when you combine and customize them.

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

    var body: some View {
        VStack {
            if showBanner {
                Text("Operation completed successfully")
                    .padding()
                    .background(.green, in: .capsule)
                    .transition(
                        .move(edge: .top)
                        .combined(with: .opacity)
                    )
            }

            Spacer()

            Button("Show Banner") {
                withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                    showBanner.toggle()
                }
            }
        }
    }
}

Asymmetric Transitions

Sometimes you want different animations for insertion and removal. That's where .asymmetric() comes in:

.transition(.asymmetric(
    insertion: .scale(scale: 0.5).combined(with: .opacity),
    removal: .move(edge: .trailing).combined(with: .opacity)
))

The view scales up and fades in when appearing, then slides out to the right when disappearing. You'll see this pattern a lot in notification systems and card interfaces.

Building Fully Custom Transitions

For complete control, you can define a custom ViewModifier and wrap it in a Transition:

struct BlurTransitionModifier: ViewModifier {
    let isActive: Bool

    func body(content: Content) -> some View {
        content
            .blur(radius: isActive ? 10 : 0)
            .opacity(isActive ? 0 : 1)
            .scaleEffect(isActive ? 0.8 : 1.0)
    }
}

extension AnyTransition {
    static var blur: AnyTransition {
        .modifier(
            active: BlurTransitionModifier(isActive: true),
            identity: BlurTransitionModifier(isActive: false)
        )
    }
}

Now any view can use .transition(.blur) for a nice frosted-glass appearance and disappearance effect. It's one of my favorite custom transitions to reach for.

PhaseAnimator: Multi-Step Animation Sequences

Introduced in iOS 17, PhaseAnimator lets you define a sequence of discrete states and have SwiftUI cycle through them automatically. This is perfect for multi-step animations like a loading pulse, a shake effect, or a sequential reveal.

enum BouncePhase: CaseIterable {
    case initial, compress, stretch, settle

    var scaleY: Double {
        switch self {
        case .initial: 1.0
        case .compress: 0.7
        case .stretch: 1.3
        case .settle: 1.0
        }
    }

    var scaleX: Double {
        switch self {
        case .initial: 1.0
        case .compress: 1.3
        case .stretch: 0.8
        case .settle: 1.0
        }
    }
}

struct BouncingBall: View {
    @State private var trigger = 0

    var body: some View {
        Circle()
            .fill(.orange.gradient)
            .frame(width: 80, height: 80)
            .phaseAnimator(
                BouncePhase.allCases,
                trigger: trigger
            ) { content, phase in
                content
                    .scaleEffect(x: phase.scaleX, y: phase.scaleY)
            } animation: { phase in
                switch phase {
                case .compress: .easeIn(duration: 0.15)
                case .stretch: .spring(duration: 0.2, bounce: 0.5)
                case .settle: .spring(duration: 0.4, bounce: 0.2)
                default: .default
                }
            }
            .onTapGesture { trigger += 1 }
    }
}

The trigger parameter controls when the animation plays. Each phase can use a different curve, giving you fine-grained control over the feel of each step.

Without a trigger, the phase animator cycles endlessly — which is actually great for ambient loading animations or attention-grabbing loops.

KeyframeAnimator: Timeline-Based Precision

While PhaseAnimator cycles through discrete states, KeyframeAnimator gives you timeline-based control with independent tracks for each property. Think of it as a mini After Effects timeline inside SwiftUI — you define specific values at specific points in time, and the framework interpolates between them.

struct ShakeValues {
    var xOffset: Double = 0
    var rotation: Double = 0
    var scale: Double = 1.0
}

struct ShakeEffect: View {
    @State private var trigger = 0

    var body: some View {
        Image(systemName: "bell.fill")
            .font(.system(size: 60))
            .foregroundStyle(.yellow)
            .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)
                    SpringKeyframe(-10, duration: 0.1)
                    SpringKeyframe(6, duration: 0.1)
                    SpringKeyframe(-6, duration: 0.1)
                    SpringKeyframe(0, duration: 0.15)
                }
                KeyframeTrack(\.rotation) {
                    SpringKeyframe(5, duration: 0.1)
                    SpringKeyframe(-5, duration: 0.1)
                    SpringKeyframe(3, duration: 0.1)
                    SpringKeyframe(-3, duration: 0.1)
                    SpringKeyframe(0, duration: 0.15)
                }
                KeyframeTrack(\.scale) {
                    CubicKeyframe(1.2, duration: 0.15)
                    CubicKeyframe(1.0, duration: 0.35)
                }
            }
            .onTapGesture { trigger += 1 }
    }
}

Each KeyframeTrack animates a single property independently. The framework gives you four keyframe types to work with:

  • LinearKeyframe — Constant-speed interpolation between values.
  • SpringKeyframe — Spring physics interpolation, great for natural bounce.
  • CubicKeyframe — Bezier curve interpolation for smooth, cinematic motion.
  • MoveKeyframe — Instant jump to a value with no interpolation.

One heads-up: keyframe animations shouldn't be interrupted mid-playback. Since exact values are defined at precise timestamps, the system can't smoothly retarget to new values if you cancel the animation partway through.

Hero Animations with matchedGeometryEffect

The matchedGeometryEffect modifier creates smooth transitions between two views that share a common identity. When one disappears and another appears, SwiftUI automatically animates the size, position, and shape between them — producing that "hero animation" effect you've probably seen in the App Store's card-to-detail transitions.

struct HeroCardView: View {
    @Namespace private var heroNamespace
    @State private var isExpanded = false

    var body: some View {
        VStack {
            if isExpanded {
                // Expanded detail view
                RoundedRectangle(cornerRadius: 20)
                    .fill(.blue.gradient)
                    .matchedGeometryEffect(id: "card", in: heroNamespace)
                    .frame(height: 400)
                    .overlay(alignment: .topTrailing) {
                        Button("Close") {
                            withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                                isExpanded = false
                            }
                        }
                        .padding()
                    }
            } else {
                // Compact card
                RoundedRectangle(cornerRadius: 12)
                    .fill(.blue.gradient)
                    .matchedGeometryEffect(id: "card", in: heroNamespace)
                    .frame(width: 160, height: 200)
                    .onTapGesture {
                        withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                            isExpanded = true
                        }
                    }
            }
        }
    }
}

The @Namespace property wrapper creates a shared namespace, and the string identifier "card" links the two views together. SwiftUI interpolates the geometry — frame, corner radius, position — producing a seamless morph effect. It's genuinely one of those things that looks way harder to build than it actually is.

Navigation Transitions with matchedTransitionSource (iOS 18+)

Here's the catch though: matchedGeometryEffect doesn't work across NavigationStack pushes. iOS 18 solved this with matchedTransitionSource combined with .navigationTransition(.zoom(...)):

NavigationLink(value: item) {
    ItemThumbnail(item: item)
        .matchedTransitionSource(id: item.id, in: heroNamespace)
}

// In the destination view:
.navigationTransition(.zoom(sourceID: item.id, in: heroNamespace))

This handles the complexity of navigation transitions automatically, including interactive back gestures. Really nice API design.

The @Animatable Macro in iOS 26

Before iOS 26, creating custom animatable shapes and views meant manually implementing the Animatable protocol and writing verbose animatableData getters and setters. If you've ever had to animate multiple properties simultaneously, you know the AnimatablePair nesting gets ugly fast.

The new @Animatable macro eliminates all that boilerplate.

@Animatable
struct WaveShape: Shape {
    var amplitude: Double
    var frequency: Double
    @AnimatableIgnored var phase: Double

    func path(in rect: CGRect) -> Path {
        var path = Path()
        let width = rect.width
        let height = rect.height
        let midY = height / 2

        path.move(to: CGPoint(x: 0, y: midY))

        for x in stride(from: 0, through: width, by: 1) {
            let relativeX = x / width
            let y = midY + amplitude * sin((relativeX * frequency * .pi * 2) + phase)
            path.addLine(to: CGPoint(x: x, y: y))
        }

        return path
    }
}

struct WaveView: View {
    @State private var amplitude: Double = 20
    @State private var frequency: Double = 3

    var body: some View {
        WaveShape(amplitude: amplitude, frequency: frequency, phase: 0)
            .stroke(.blue, lineWidth: 3)
            .frame(height: 200)
            .onTapGesture {
                withAnimation(.spring(duration: 1.0, bounce: 0.4)) {
                    amplitude = Double.random(in: 10...50)
                    frequency = Double.random(in: 2...8)
                }
            }
    }
}

The macro automatically synthesizes the animatableData property for all stored properties. Anything you don't want animated — like the phase offset here — just gets marked with @AnimatableIgnored.

It works on Shape, View, ViewModifier, and TextRenderer types. The only requirement is that your animatable properties conform to VectorArithmetic (most numeric types like Double, CGFloat, and AnimatablePair already do).

Animation Performance Best Practices

Smooth animations need consistent 60fps (or 120fps on ProMotion displays). Here's what you should keep in mind:

  • Prefer animating transforms over layout changes. Animating .scaleEffect, .rotationEffect, .offset, and .opacity is GPU-accelerated and cheap. Animating .frame() triggers a full layout pass on every frame — and you'll feel it.
  • Use .drawingGroup() for complex view trees. This rasterizes the view into a single bitmap before compositing, cutting down the number of render operations during animation.
  • Avoid animating views with tons of subviews. If you must animate a complex container, flatten it with .drawingGroup() or simplify the hierarchy during the animation.
  • Profile with Instruments. Xcode 26's SwiftUI performance instrument includes dedicated lanes for long view body updates and dropped frames during animations. Use them.
  • Use .geometryGroup() in iOS 17+. When parent and child views animate at different rates, wrapping the parent in a geometry group resolves the intermediate layout into a concrete frame, preventing visual glitches.

Choosing the Right Animation Tool

SwiftUI's animation system is layered, which is great — but it can feel overwhelming at first. Here's a quick decision framework to help you pick:

  • Single property, one view: Implicit .animation() modifier — the simplest approach.
  • Multiple views, one state change: Explicit withAnimation {} — animates everything affected by the mutation.
  • Multi-step sequential animation: PhaseAnimator — define discrete phases, each with its own curve.
  • Complex choreography with independent timing: KeyframeAnimator — timeline tracks with precise keyframe control.
  • View-to-view morphing: matchedGeometryEffect — hero transitions within the same hierarchy.
  • Navigation hero transitions: matchedTransitionSource + .navigationTransition(.zoom) — hero animations across NavigationStack.
  • Custom animatable shapes or views: @Animatable macro (iOS 26) — automatic protocol synthesis.

Frequently Asked Questions

What is the difference between implicit and explicit animations in SwiftUI?

Implicit animations are attached to a specific view using the .animation() modifier and trigger whenever the watched value changes. Explicit animations wrap a state change in withAnimation {} and animate every view that reads the mutated state. Use implicit for view-specific effects and explicit when a single action should ripple across multiple views.

How do I create a spring animation in SwiftUI?

Use withAnimation(.spring(duration: 0.5, bounce: 0.3)) { ... } for explicit springs, or .animation(.spring(duration: 0.5, bounce: 0.3), value: someValue) for implicit ones. The duration controls perceived length, and bounce controls overshoot (0 = no bounce, values near 1 = maximum oscillation). For gesture-driven animations, reach for .interactiveSpring() instead.

When should I use PhaseAnimator vs. KeyframeAnimator?

Use PhaseAnimator when your animation moves through discrete steps — like a multi-step loading indicator or a shake effect with defined states. Use KeyframeAnimator when you need timeline-based control with independent tracks for different properties, each with their own timing. The bell shake example earlier in this guide is a good illustration of when keyframes are the right call.

Can matchedGeometryEffect work with NavigationStack?

Not directly, no. The original matchedGeometryEffect doesn't work across NavigationStack push/pop transitions. For hero animations within navigation, iOS 18 introduced matchedTransitionSource paired with .navigationTransition(.zoom(sourceID:in:)). It handles geometry interpolation across navigation transitions, including interactive swipe-back gestures.

What is the @Animatable macro in iOS 26?

The @Animatable macro automatically synthesizes conformance to the Animatable protocol by generating the animatableData property from your type's stored properties. It eliminates all the boilerplate previously required for custom animatable shapes, views, and view modifiers. Mark any properties you don't want animated with @AnimatableIgnored.

About the Author Editorial Team

Our team of expert writers and editors.