SwiftUI Tutorial

SwiftUI Animation: From withAnimation to Keyframes and Animatable

The animation modifiers you'll actually reach for — value-based .animation, withAnimation blocks, transitions for insert/remove, matchedGeometryEffect for hero moves, springs vs curves, and the newer completion, phase, and keyframe APIs. Real Swift 6 code.

Last updated: 2026-07-17 11 min read By Ahmed Gagan, iOS Engineer
Quick Answer

Animate state changes two ways: attach .animation(_:value:) to animate whenever a specific value changes, or wrap the mutation in withAnimation { } to animate that change explicitly. Use .transition for views entering and leaving, matchedGeometryEffect to morph one view into another, and prefer spring animations for natural motion. iOS 17+ adds an animation completion callback plus phase and keyframe animators, and iOS 26 introduces the @Animatable macro.

Two triggers
.animation(_:value:) or withAnimation { }
Enter/exit
.transition combined with an animated insertion/removal
Modern curves
Prefer .spring / .snappy / .bouncy over fixed easeInOut
Swift Kit support
The Swift Kit's design system centralizes animation tokens, so screen transitions and button feedback feel consistent app-wide.

The two ways to trigger an animation

SwiftUI gives you two entry points and choosing correctly avoids most animation surprises. The value-based modifier .animation(_:value:) animates any change that results from a specific value mutating — attach it and whenever that value changes, the affected properties interpolate. The imperative form, withAnimation { }, animates the state changes you make inside the closure and nothing else, which gives you precise scope. The old .animation(_:) with no value is deprecated because it animated indiscriminately, causing unrelated changes to move. Reach for withAnimation when a single event should animate several properties, and .animation(_:value:) when a property should always animate as it changes.

value-based vs withAnimation
struct TriggerDemo: View {
    @State private var expanded = false

    var body: some View {
        VStack(spacing: 24) {
            // Always animates when `expanded` changes:
            RoundedRectangle(cornerRadius: 12)
                .frame(width: expanded ? 240 : 120, height: 80)
                .animation(.spring, value: expanded)

            Button("Toggle") {
                // Explicitly scope the animation to this change:
                withAnimation(.snappy) { expanded.toggle() }
            }
        }
    }
}

Transitions for insert and remove

Position and size changes interpolate automatically, but a view appearing or disappearing needs a transition to describe how. Attach .transition to a view that is conditionally present, then toggle its presence inside an animation so SwiftUI knows to animate the insertion and removal. Built-ins include .opacity, .scale, .slide, and .move(edge:), and you combine them with .combined(with:) or split insertion and removal using .asymmetric. A frequent mistake is expecting a transition to run without an animating container — the condition that adds or removes the view must change inside withAnimation (or under an .animation(value:)) for the transition to play at all.

An asymmetric transition on a banner
struct BannerDemo: View {
    @State private var showBanner = false

    var body: some View {
        VStack {
            Button("Toggle Banner") {
                withAnimation(.easeInOut) { showBanner.toggle() }
            }
            if showBanner {
                Text("Saved!")
                    .padding()
                    .background(.green.opacity(0.2), in: Capsule())
                    .transition(.asymmetric(
                        insertion: .move(edge: .top).combined(with: .opacity),
                        removal: .opacity))
            }
        }
    }
}

matchedGeometryEffect for hero moves

matchedGeometryEffect creates the illusion of a single view moving between two places — a thumbnail expanding into a detail view, a tab indicator sliding under the selected tab. You give two views the same id within a shared @Namespace, and when one is removed and the other inserted, SwiftUI interpolates position and size between them so it reads as one element traveling. The key rules: both views must share the same namespace and id, only one should be present at a time for a clean morph, and the change must happen inside an animation. It's the tool behind most polished 'hero' transitions and the animated selection pill in custom tab bars.

An animated selection pill with matchedGeometryEffect
struct SegmentedPicker: View {
    @Namespace private var ns
    @State private var selection = 0
    let titles = ["Day", "Week", "Month"]

    var body: some View {
        HStack {
            ForEach(titles.indices, id: \.self) { i in
                Text(titles[i])
                    .padding(.vertical, 8).padding(.horizontal, 16)
                    .background {
                        if selection == i {
                            Capsule().fill(.blue.opacity(0.2))
                                .matchedGeometryEffect(id: "pill", in: ns)
                        }
                    }
                    .onTapGesture { withAnimation(.snappy) { selection = i } }
            }
        }
    }
}

Spring vs easeInOut, and the completion API

For UI that responds to touch, springs feel more natural than fixed-duration curves because they carry momentum. Modern SwiftUI offers named springs — .spring, .bouncy, .snappy, .smooth — that you can use without tuning stiffness and damping by hand; reserve custom Spring(response:dampingFraction:) for when a named one isn't quite right. Reserve .easeInOut and friends for deterministic, non-interactive motion like a progress sweep. Since iOS 17 you can also run code when an animation finishes via the completion overload of withAnimation, which finally removes the old trick of guessing a delay to chain follow-up work after an animation lands.

  • Springs (.snappy, .bouncy, .smooth) for touch-driven motion
  • .easeInOut / .linear for deterministic, non-interactive sweeps
  • withAnimation(_:completion:) runs code when the animation completes (iOS 17+)
  • Avoid hard-coded delays to sequence animations
Chaining work with the completion callback
struct CompletionDemo: View {
    @State private var scale = 1.0
    @State private var showDone = false

    var body: some View {
        Circle().frame(width: 80, height: 80).scaleEffect(scale)
            .onTapGesture {
                withAnimation(.bouncy) { scale = 1.4 } completion: {
                    withAnimation { showDone = true }
                }
            }
    }
}

Phase, keyframe animators, and Animatable in iOS 26

For multi-step animations, iOS 17 added two declarative tools. PhaseAnimator cycles a view through an array of phases, letting you describe distinct states (rest, jiggle, settle) and the animation between each — perfect for looping attention effects. KeyframeAnimator drives multiple properties along independent timelines, the way you'd storyboard a bouncing icon whose scale, rotation, and offset peak at different moments. Both keep complex motion declarative instead of nesting DispatchQueue delays. Looking ahead, iOS 26 introduces the @Animatable macro, which synthesizes the animatableData conformance for your custom Shapes and views so you can animate custom properties without hand-writing the AnimatablePair boilerplate.

A looping attention effect with PhaseAnimator
struct PulseIcon: View {
    var body: some View {
        Image(systemName: "bell.fill")
            .font(.largeTitle)
            .phaseAnimator([1.0, 1.2, 1.0]) { view, scale in
                view.scaleEffect(scale)
            } animation: { _ in
                .easeInOut(duration: 0.6)
            }
    }
}

// iOS 26: @Animatable synthesizes animatableData for custom shapes
// @Animatable struct Wave: Shape { var phase: Double; func path(in rect: CGRect) -> Path { /* ... */ Path() } }

Consistent motion, defined once

The Swift Kit centralizes animation curves and durations as design-system tokens, so every screen transition and button press shares the same polished feel.

Get The Swift Kit — $99

How to build a hero transition between two views

matchedGeometryEffect makes a thumbnail appear to expand into a detail card.

  1. 1

    Create a namespace

    Declare a shared @Namespace in the parent that owns both states.

    @Namespace private var ns
  2. 2

    Tag both views

    Give the collapsed and expanded views the same id in that namespace.

    .matchedGeometryEffect(id: item.id, in: ns)
  3. 3

    Toggle inside an animation

    Switch which view is shown within withAnimation so SwiftUI interpolates.

    withAnimation(.spring) { selectedItem = item }
  4. 4

    Show one at a time

    Ensure only the collapsed or expanded view is present for a clean morph.

    if selectedItem == nil { Thumbnail() } else { DetailCard() }

Frequently Asked Questions

Why does my SwiftUI .transition not animate when a view appears or disappears?
A transition only plays when the condition that inserts or removes the view changes inside an animation. Wrap the toggle in withAnimation, or drive the surrounding state with .animation(value:). A .transition on a view whose presence flips outside any animation does nothing.
What's the difference between .animation(_:value:) and withAnimation in SwiftUI?
.animation(_:value:) is declarative — it animates whenever the specified value changes, always. withAnimation { } is imperative and scopes the animation to exactly the state changes inside its closure. Use the value form for a property that should always animate, and withAnimation when one event should animate several properties at once.
How do I run code after a SwiftUI animation finishes instead of guessing a delay?
Since iOS 17 use the completion overload: withAnimation(.spring) { state.change() } completion: { /* runs when it lands */ }. It fires when the animation truly finishes, so you can chain follow-up animations or work without hard-coding a matching delay that breaks when timing changes.
When should I use PhaseAnimator versus KeyframeAnimator in SwiftUI?
PhaseAnimator cycles a view through an ordered set of discrete phases with one animation between each — ideal for looping attention effects. KeyframeAnimator drives multiple properties along independent timelines, so scale, rotation, and offset can peak at different moments — ideal for storyboarded, multi-track motion.
What does the iOS 26 @Animatable macro do for custom SwiftUI shapes?
@Animatable synthesizes the animatableData conformance for your custom Shape or view, so animating a custom property no longer requires hand-writing an AnimatablePair and the animatableData getter/setter. You annotate the type and SwiftUI generates the interpolation plumbing for you.

Keep exploring

Motion that feels designed, not bolted on

The Swift Kit is a $99 one-time SwiftUI boilerplate with a token-driven design system, onboarding, and paywalls — so animations stay consistent as your app grows.

Get The Swift Kit — $99

One-time purchase · Lifetime updates · 14-day refund